Добрый день, читатели Хабра. Хотел бы поделиться своим опытом разработки приложения для Android TV на примере DetailsFragment.


Есть официальные примеры тут и официальная документация тут. Что сподвигло меня выразить свое мнение? Это то, что официальные примеры не отвечают современным требованиям к разработке, например, модульности и расширяемости. Иногда создается некая двойственность при использовании того или иного механизма. Рассмотрим более подробно DetailsFragment.

Для того чтобы начать разрабатывать ваше приложение для платформы android, на мой взгляд, вам следует принять 2 основные истины:

  • Плохая идея отходить от официальных рекомендаций и разрабатывать кастомизированное приложение. Гугл позаботился о том, чтобы сделать это было крайне сложно.
  • Single Activity Architecture также не совсем подходит, это чревато утечками памяти, связанной с внутренними реализации библиотеки leanback.

Итак, обо всём по порядку:

Коротко о библиотеке Leanback


Библиотека Leanback представляет собой набор шаблонов экранов с различными функциональными особенностями. Есть экраны для отображения списков, карточек контента, диалогов и т. д. Эти экраны обрабатывают все пользовательские переходы между элементами и анимации, а также имеют довольно обширный функционал для построения простых приложений “из коробки”. Идеология данной библиотеки заключается в том, что все приложения на ее основе должны быть похожи в плане пользования. Мое личное мнение — это довольно удачная идея создания единообразия приложений на рынке. Мне не нужно больше думать, узнает ли пользователь о том что можно прокрутить вниз? Узнает, потому что он уже пользовался сотнями однотипных приложений.

Но, как и в большом количестве подключаемых к проекту библиотек, находятся свои острые несостыковки ожиданий заказчика от продукта и возможностей этой библиотеки. На одном примере я постараюсь сделать два основных вывода, которые я вывел для себя, разрабатывая это приложение.

Итак, класс DetailsFragment


Данный класс служит для отображения “карточки контента”. Карточки контента — это пространство экрана, которое отображает полную информацию о том или ином объекте сущности. В большинстве случаев, когда пользователь нажимает что-то в списке похожих объектов сущностей, он попадает именно на карточку контента.

Что такое DetailsFragment


Данный класс является собирательным образом пользовательских представлений. Логическая структура выглядит следующим образом:



Разберемся по порядку, какой элемент чем занимается.

  • ArrayObjectAdapter — это класс, который занимается сбором всех элементов на экране.
  • DetailsOverviewRow — часть главного адаптера, отвечает за отображение функциональных элементов (Actions), за информацию (DescriptionView) и картинка карточки контента.
  • Additional Row — в этот ряд попадают дополнительные элементы, расширяющие функциональность карточки контента.

И так на первый взгляд все просто мы имеем некую “матрешку”, в которой четко распределены роли (на самом деле нет, далее мы в этом убедимся).

Остановлюсь лишь на основных понятиях DetailsOverviewRow, потому что они действительно являются на мой взгляд довольно интересными.

Класс DetailsOverviewRow имеет следующие основные методы:

  • setImageDrawable(Drawable drawable) — данный метод устанавливает “аватарку” нашей карточки контента. Альтернативным методом установки аватарки может являться setImageBitmap(Context context, Bitmap bm).
  • void setActionsAdapter(ObjectAdapter adapter) — установка адаптера для событий карточки контента (например, купить/смотреть/добавить в избранное и так далее). ObjectAdapter это абстрактный класс. В leanback есть несколько реализаций на классических структурах (например ArrayObjectAdapter). Мы можем добавлять в наш ObjectAdapter разные классы, в данном случае можно прибегнуть к стандартному классу Action.
  • Для установки DescriptionView используется конструктор класса DetailsOverviewRow, который принимает некую модель.

Интересно, как DetailsOverviewRow ставит нашу модель с информацией на отображение? Где берется разметка для этого отображения?

Presenter (не классический MVP)


Так уж получилось, что Гугл назвал класс, который отвечает за то, как выглядит та или иная модель в рамках внутренних вью, presenter. Далее, для удобства, я буду называть его UI презентер.

Итак, UI презентеры, это по сути то, как наши объекты данных или объекты сущности попадают на экран. Если провести аналогию с классической android разработкой, этим всем занимается адаптер.

В случае с DescriptionView нам нужно создать ui презентер, который будет ставить модель на заданное пользовательское представление. Есть 2 основных способа, которым мы можем это сделать:

  • FullWidthDetailsOverviewRowPresenter — данный класс является наследником RowPresenter. Он является полноэкранным отображением DetailsOverviewRow. Внешний вид:

  • DetailsOverviewRowPresenter — больше не поддерживается. Отображался как на картинке ниже


В итоге мы выбираем FullWidthDetailsOverviewRowPresenter, так как альтернатив “из коробки” нет.

Создание ui презентера будет выглядеть следующим образом:

FullWidthDetailsOverviewRowPresenter rowPresenter = 
new FullWidthDetailsOverviewRowPresenter(
                new DetailsDescriptionPresenter(context))

DetailsDescriptionPresenter класс, который расширяется от Presenter. Отвечает за пользовательское отображение объекта сущности (чаще всего там указывается название и описание).

Как говорилось ранее, ui презентер — аналог адаптера в классическом android. Следующие методы обязательны для реализации:

  • ViewHolder onCreateViewHolder(ViewGroup parent) — данный метод предназначен для создания объекта ViewHolder. Тут мы можем создать наше пользовательское представление и передать его во ViewHolder.
  • void onBindViewHolder(ViewHolder viewHolder, Object item) — метод построения нашего ViewHolder. Как можно заметить, тут немного скользкая ситуация, так как объект данных передается как java объект. Можно получить ошибку времени выполнения если неверно использовать нисходящее преобразование.
  • void onUnbindViewHolder(ViewHolder viewHolder) — этот метод служит для освобождения нашего холдера от ресурсов, чтобы сборщик мусора мог благополучно удалить его.

Общая картина


По всему проекту android TV вы будете использовать ArrayObjectAdapter с кастомными презентами, возможно будете применять фабрики презентеров. Стоить запомнить, что они просто вкладываются друг в друга и в реализации конкретного экрана дают те или иные формы представления. Например я создал свой класс-наследник ui презентера, назвал его AbstractCardPresenter. Меня этот класс не раз выручал так как сглаживает неровности с преобразованиями на уровне их появления. Также создал базовое представление карточек. Это помогло мне переиспользовать готовые виды там где они требуются и частично кастомизировать карточки.

AbstractCardPresenter
public abstract class AbstractCardPresenter<T extends BaseCardView> 
extends Presenter {

private static final String TAG = "AbstractCardPresenter";
private final Context mContext;
    
public AbstractCardPresenter(Context context) {
        mContext = context;
    }

public Context getContext() {
        return mContext;
    }

@Override 
public final ViewHolder onCreateViewHolder(ViewGroup parent) {
        T cardView = onCreateView();
        return new ViewHolder(cardView);
    }

@Override 
public final void onBindViewHolder(ViewHolder viewHolder, Object item) {
        Card card = (Card) item;
        onBindViewHolder(card, (T) viewHolder.view);
    }

@Override 
public final void onUnbindViewHolder(ViewHolder viewHolder) {
        onUnbindViewHolder((T) viewHolder.view);
    }

public void onUnbindViewHolder(T cardView) {
    }
    
protected abstract T onCreateView();
    
public abstract void onBindViewHolder(Card card, T cardView);

}


“плохая идея отходить от официальных рекомендаций”?


Плохая она из-за того, что в классах, которые были заботливо написаны за нас, большая часть методов является неизменяемыми по простой причине сильной внутренней связанности. Чтобы не нарушить внутреннее состояние экрана (по сути DetailsFragment и прочие являются фрагментами), следует использовать их так как задумывалось. Не буду вдаваться в подробности реализации внутренних классов, state machine и прочих идей разработчиков данной библиотеки. Реальный пример из моей работы — утечка фрагмента целиком при использовании Single Activity Architecture.

Данная утечка была связана с переходами DetailsFragment. Путем проб и ошибок удалось найти причину утечки, устранить утечку и написать в баг репорт. С учетом малой мощности самих телевизоров (Sony Brawia 4K 2GB RAM) проблема OOM встает довольно остро. Утечка устраняется обнулением этих переходов. При использовании переходов между активити данная проблема не наблюдалась.

TransitionHelper.setReturnTransition(getActivity().getWindow(), null);
TransitionHelper.setEnterTransition(getActivity().getWindow(), null);

Из коробки не работает!


Если очень хочется (требует заказчик) изменить то или иное отображение, это можно сделать, я расскажу на примере с которым я столкнулся. За свой опыт разработки под android tv я повидал много сдерживающих факторов: невозможно отслеживать внутренние фрагменты созданные библиотекой; их жизненный цикл никем не контролируется; вызовы создания пользовательских представлений в конструкторах (асинхронные данные при этом невозможно использовать). Гугл сделал почти все чтобы нельзя было написать “как правильно”. С учетом современных запросов, не гибкий механизм оказывается плох и не нужен, но так как альтернатив нету (кроме написания собственного leanback), приходиться жить с тем что есть.

Первым что привлекло мое внимание к реализации из коробки то это аватарка карточки контента. При переключении фокуса вниз-вверх она абсолютно неанимировано дергается вниз.

Пример


Сузив поиски классов, которые отвечают за этот вид, я направился в реализацию класса FullWidthDetailsOverviewRowPresenter, чтобы найти ответ на вопрос как она перемещается. Мне удалось найти метод, который отвечает за перемещение аватарки нашей карточки контента — void onLayoutLogo(ViewHolder viewHolder, int oldState, boolean logoChanged).

Реализация по умолчанию была следующая:

/**
     * Layout logo position based on current state.  Subclass may override.
     * The method is called when a logo is bound to view or state changes.
     * @param viewHolder  The row ViewHolder that contains the logo.
     * @param oldState    The old state,  can be same as current viewHolder.getState()
     * @param logoChanged Whether logo was changed.
     */
    protected void onLayoutLogo(ViewHolder viewHolder, int oldState, boolean logoChanged) {
        View v = viewHolder.getLogoViewHolder().view;
        ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams)
                v.getLayoutParams();
        switch (mAlignmentMode) {
            case ALIGN_MODE_START:
            default:
                lp.setMarginStart(v.getResources().getDimensionPixelSize(
                        R.dimen.lb_details_v2_logo_margin_start));
                break;
            case ALIGN_MODE_MIDDLE:
                lp.setMarginStart(v.getResources().getDimensionPixelSize(R.dimen.lb_details_v2_left)
                        - lp.width);
                break;
        }

        switch (viewHolder.getState()) {
        case STATE_FULL:
        default:
            lp.topMargin =
                    v.getResources().getDimensionPixelSize(R.dimen.lb_details_v2_blank_height)
                    - lp.height / 2;
            break;
        case STATE_HALF:
            lp.topMargin = v.getResources().getDimensionPixelSize(
                    R.dimen.lb_details_v2_blank_height) + v.getResources()
                    .getDimensionPixelSize(R.dimen.lb_details_v2_actions_height) + v
                    .getResources().getDimensionPixelSize(
                    R.dimen.lb_details_v2_description_margin_top);
            break;
        case STATE_SMALL:
            lp.topMargin = 0;
            break;
        }
        v.setLayoutParams(lp);
    }

Реализация найдена, далее я создал класс-наследник FullWidthDetailsOverviewRowPresenter в котором я переопределил метод onLayoutLogo и написал свою реализацию.

public class CustomMovieDetailsPresenter extends FullWidthDetailsOverviewRowPresenter {
	
	private int mPreviousState = STATE_FULL;
	
	public CustomMovieDetailsPresenter(final Presenter detailsPresenter) {
		super(detailsPresenter);
		setInitialState(FullWidthDetailsOverviewRowPresenter.STATE_FULL);
	}
	
	@Override
	protected void onLayoutLogo(final ViewHolder viewHolder, final int oldState, final boolean logoChanged) {
		final View v = viewHolder.getLogoViewHolder().view;
		final ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) v.getLayoutParams();
		
		lp.setMarginStart(v.getResources().getDimensionPixelSize(
			android.support.v17.leanback.R.dimen.lb_details_v2_logo_margin_start));
		lp.topMargin = v.getResources().getDimensionPixelSize(android.support.v17.leanback.R.dimen.lb_details_v2_blank_height) - lp.height / 2;
		
		switch (viewHolder.getState()) {
			case STATE_FULL:
			default:
				if (mPreviousState == STATE_HALF) {
					v.animate().translationY(0);
				}
				
				break;
			case STATE_HALF:
				if (mPreviousState == STATE_FULL) {
					final float offset = v.getResources().getDimensionPixelSize(android.support.v17.leanback.R.dimen.lb_details_v2_actions_height) + v
						.getResources().getDimensionPixelSize(android.support.v17.leanback.R.dimen.lb_details_v2_description_margin_top)+lp.height/2;
					v.animate().translationY(offset);
				}
				
				break;
		}
		mPreviousState = viewHolder.getState();
		v.setLayoutParams(lp);
	}
}

Результат


В данном случае все решилось относительно просто, при состоянии когда экран спускается вниз, мы анимировано спускаем за ним и аватарку карточки контента. При возврате на стартовую позицию также анимировано смещаем аватарку вверх. Но бывают случаи, что метод объявлен финальным или является недоступным, тогда я прибегал к рефлексии. Рефлексия это крайняя стадия, так как имеет ряд недостатков:

  • При обновлении библиотеки может случиться так, что поле, к которому мы получали доступ через рефлексию, переименовывается, или еще хуже — удаляется
  • Рефлексия довольно ресурсоемкая операция, которая часто вызывает затруднения в операционной системы
  • Рефлексию трудно читать и сложно поддерживать

Иными словами рефлексия это крайняя инстанция, к которой я прибегал один раз. Но как — механизм я запомнил.

Немного о многослойной архитектуре в android tv приложении


В данном случае все относительно просто, проблемы могут возникать только в слое пользовательских представлений, так как иногда сложно понять куда именно относится тот или иной элемент. Возвращаясь к нашему примеру с DetailsFragment, реальными задачами будут примерно следующие: Если контент куплен, то отобразить кнопку “Смотреть”; Если контент арендован, то отображать кнопку смотреть + время окончания аренды и т.д.; При всем этом есть кнопка трейлер, кнопка добавить в избранное и т.д. По моему мнению, презентер (MVP) должен получать какую то модель и вызывать метод addAction(android.support.v17.leanback.widget.Action action). То есть презентер на основании данных делает вывод о том, какие кнопки следует добавить, генерирует их и вызывает соответствующий метод внешнего интерфейса вью. Тут проявляется проблема зависимости презентера от библиотеки leanback. Так как, по хорошему, нужно использовать этот презентер и в других частях нашей программы, например на мобильном устройстве, проблема встает довольно резко. Тем самым я ввел правило разработки презентеров в проект, в котором я участвую — не объявлять неявных зависимостей в презентере, которые привязаны к фреймворку.

Чтобы это избежать было принято решение в презентерах использовать локальный аналог android.support.v17.leanback.widget.Action. Это решило множество проблем в презентере, но породило двоякую логику во вью, связанную с обработкой позиции добавления и обработки нажатия, так как во вью мы вполне можем оперировать виджетами, предоставленными leanback’ом. Такая же двоякая логика появляется когда неизвестен набор кнопок изначально, но они имеют некие приоритеты. Например кнопка “смотреть” должна быть перед кнопкой трейлер, кнопка купить должна быть после трейлера и тд. Соответственно во вью появляется некий механизм который сопоставляет идентификаторы кнопок и их позиции, что делает из идентификатора некий “приоритет к показу”. Я обходил эту ситуацию довольно тривиально, но опять же вью начинает приобретать логику и знает что это не просто идентификатор.

private final List<Integer> mActionsIndexesList = new ArrayList<>();
	
@Override
public void addAction(final MovieDetailAction movieDetailAction) {
	final Action action = new Action(movieDetailAction.getId(), 
            movieDetailAction.getTitle(), 
            movieDetailAction.getSubTitle(), 
            movieDetailAction.getIcon());
	actionAdapter.set(movieDetailAction.getId(), action);
	mActionsIndexesList.add(movieDetailAction.getId());
	Collections.sort(mActionsIndexesList);
	}

@Override
public void setSelectedAction(final int actionId) {
		new Handler().postDelayed(() -> 
mActionsGridView.smoothScrollToPosition(getActionPositionByActionId(actionId)), 100);
	}

	private int getActionPosition(final int actionId) {
		return mActionsIndexesList.indexOf(actionId);
	}

В заключении


Разработка приложений для Android Tv является относительно новой, а посему интересной. На момент написания статьи сообщество разработчиков Android Tv является децентрализованным и поэтому большинство проблем решается “протаптыванием своих дорожек”. Также, на мой взгляд, является довольно интересным программирование в рамках ограниченных ресурсов (оперативной памяти, вычислительной мощности и тд). Такой шаблон мышления не всегда свойственен разработчикам классических android приложений и по моему мнению является полезным опытом.

Комментарии (3)


  1. dmbreaker
    15.03.2018 07:33

    А где большой брат?


    1. morkovkin Автор
      15.03.2018 19:40

      Большим братом тут выступает Google. Eго «забота» о разработчиках android tv приложений, выражена в создании сложно кастомизированного фреймовка leanback.


      1. dmbreaker
        15.03.2018 20:03

        Ощущение, что вы не понимаете выражения «Большой брат» и 1984 не читали.
        Из-за заголовка подумал, что тут «скандалы, интриги, расследования»… А оказалось ничего.