Leanback Support Libraryを25.0.1に更新した話

# Leanback以外は対応不要でした
仕事で担当しているAndroidアプリ（Android TVにも対応）のSupport Libraryを更新したので、そのときに対応したことのメモです。
モバイルとTVで同一apkをビルドしているのですが、今回の更新でコード変更が必要だったのはLeanback関連のみで、それ以外のSupport Libraryは特に対応する必要がありませんでした。

# 更新前後のバージョン
`23.2.1` → `25.0.1` に更新しました。

### 24系をスキップしてます
最初まずワンクッションおくために `24.2.1` に上げようとしたのですが、以下のエラーが出まして 

    Error: Attribute "dotRadius" already defined with incompatible format.

で、調べたところ、どうやら`support.wearable`と`support.v17.leanback`を同時に使用しているとこのエラーが出てビルドが失敗する模様。25で解消したようなので、24はすっとばして25に上げました。

# 対応した点もろもろ
今回の更新にあたり、コードの変更が必要になった点と対応内容です。あくまで私が対応した内容なので、これ以外にも対応が必要な部分はあると思います。
なお、以下でLeanback提供の`XxxFragment`と記述している部分は、すべて`XxxSupportFragment`と読み替えていただいても構いません（私が実際に使っているのは`XxxSupportFragment`のほうです）。

## OnItemViewClickedListener / OnItemViewSelectedListener
### 変更された点
`OnItemViewClickedListener`、`OnItemViewSelectedListener`、どちらも変更内容は同じなので、以下、`OnItemViewClickedListener`を例にして書きます。

`OnItemViewClickedListener`の親interfaceとして`BaseOnItemViewClickedListener<T>`が定義されました。

```java:BaseOnItemViewClickedListener.java
public interface BaseOnItemViewClickedListener<T> {

    public void onItemClicked(Presenter.ViewHolder itemViewHolder, Object item,
                              RowPresenter.ViewHolder rowViewHolder, T row);
}
```

```java:OnItemViewClickedListener.java
public interface OnItemViewClickedListener extends BaseOnItemViewClickedListener<Row> {

}
```

そして、`RowsFragment`を始めとした各Fragmentの`setOnItemViewClickedListener`の引数の型も`BaseOnItemViewClickedListener`になっています。普通にコードを書いている場合は何も影響はない変更ですが、`setOnItemViewClickedListener`コール時にLambdaを使用していると、ここが`BaseOnItemViewClickedListener`として評価されてしまうので、リスナーメソッドの第4引数である`row`の型が不定で`Object`として判断されます。
例えば、以下のように書いている場合があると思いますが、これは23ではOKで25ではコンパイルエラーになるコードです。

```java:MyRowsFragment.java
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    View view = super.onCreateView(inflater, container, savedInstanceState);
    
    // set listener.
    setOnItemViewClickedListener((itemViewHolder, item, rowViewHolder, row) -> {

        switch ((int) row.getId()) {   // 25ではrowがObject型になるのでエラー
            // ・・・
        }
    });

    return view;
}
```

### 対応内容
ここはおとなしく、`setOnItemViewClickedListener`ではLambdaをあきらめました。

```java:MyRowsFragment.java
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    View view = super.onCreateView(inflater, container, savedInstanceState);
    
    // set listener.
    setOnItemViewClickedListener(new OnItemViewClickedListener() {

        @Override
        public void onItemClicked(Presenter.ViewHolder itemViewHolder, Object item, RowPresenter.ViewHolder rowViewHolder, Row row) {

            switch ((int) row.getId()) {   // OK
                // ・・・
            }
        }
    });

    return view;
}
```

`Row rowObj = (Row) row;` とか書くよりは、おとなしくLambda使わないほうがいいですよね・・・。

## RowsFragment
### 変更された点
フォーカスが当たっている`Row`の表示位置が、23では画面中央だったのが、25でWindow最上部になってしまいました。これ[Overscan領域](https://developer.android.com/training/tv/start/layouts.html#overscan)も考慮されてないし、該当の`Row`に`HeaderItem`が設定されてても`HeaderItem`部分が表示されない（フォーカス行のコンテンツ部が画面最上段になるので、ヘッダ部は画面外になってしまう）しで、これは意図した変更なのか疑問です。

フォーカス行を画面上部に設定しているのは`RowsFragment`のこの部分で、23の時点ではメソッド名は違いますが、同様の機能のメソッドはすでに実装済みではありました。

```java:RowsFragment.java
@Override
public void setAlignment(int windowAlignOffsetFromTop) {
    mAlignedTop = windowAlignOffsetFromTop;
    final VerticalGridView gridView = getVerticalGridView();

    if (gridView != null) {
        gridView.setItemAlignmentOffset(0);
        gridView.setItemAlignmentOffsetPercent(
                VerticalGridView.ITEM_ALIGN_OFFSET_PERCENT_DISABLED);
        gridView.setItemAlignmentOffsetWithPadding(true);
        gridView.setWindowAlignmentOffset(mAlignedTop);
        // align to a fixed position from top
        gridView.setWindowAlignmentOffsetPercent(
                VerticalGridView.WINDOW_ALIGN_OFFSET_PERCENT_DISABLED);
        gridView.setWindowAlignment(VerticalGridView.WINDOW_ALIGN_NO_EDGE);
    }
}
```

ただ、23の時点では実装者が明示的にメソッドを呼び出さないと実行されていませんでしたが、25では`RowsFragment#onCreateView`内で呼ばれるように変更が加えられています。

```java:RowsFragment.java
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
    // ・・・

    setAlignment(mAlignedTop);

    // ・・・
}
```

コードを読む限りでは`mAlignedTop`を`mAlignedTop`に代入して、`VerticalGridView `が取得できたら`alignmentOffset`を設定する・・・という感じで、実装者がなにもしなければ`mAlignedTop`は初期値0のままなので、画面上部に張り付くのも納得です。

### 対応内容
原因は特定できたので、23と同じような動きをしたければ、該当のメソッドを無効化するしかなさそうです。 `RowsFragment#onCreateView`から呼ばれちゃってるので、呼ばないという選択肢がとれないのはもどかしいですね・・・。

```java:MyRowsFragment.java
@Override
public void setAlignment(int windowAlignOffsetFromTop) {
    // no-op
}
```

もしくは、こんな感じでベースの`RowsFragment`を作っておけば、静的にフォーカス行の位置を指定したい場合でも動きそうです。

```java:MyBaseRowsFragment.java
@Override
public void setAlignment(int windowAlignOffsetFromTop) {
    if (windowAlignOffsetFromTop <= 0) {
        return;
    }
    super.setAlignment(int windowAlignOffsetFromTop);
}
```


## BrowseFragment
### 変更された点
`ObjectAdapter`を`VerticalGridView`に設定するタイミングが変更されています。
具体的には、以下のように`BrowseFragment#setAdapter`を呼んでいると、`ObjectAdapter`がセットされず画面に何も表示されません。

```java:MyBrowseFragment.java
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    View view = super.onCreateView(inflater, container, savedInstanceState);

    mAdapter = new new ArrayObjectAdapter(new ListRowPresenter());
    setAdapter(mAdapter);

    return view;
}
```

これは`ObjectAdapter`と`VerticalGridView`が関連づけられてない、という状態になるので、あとからいくら`notifyItemChanged`系のメソッドを呼んでも無駄です。
理由は`BrowseFragment`のコードを読むとわかります。

```java:BrowseFragment.java
HeadersFragment mHeadersFragment;
private ObjectAdapter mAdapter;

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    // ・・・

    mHeadersFragment.setAdapter(mAdapter);

    // ・・・
}

public void setAdapter(ObjectAdapter adapter) {
    mAdapter = adapter;
    createAndSetWrapperPresenter();
    if (getView() == null) {
        return;
    }
    replaceMainFragment(mSelectedPosition);

    if (adapter != null) {
        if (mMainFragmentRowsAdapter != null) {
            mMainFragmentRowsAdapter.setAdapter(new ListRowDataAdapter(adapter));
        }
        mHeadersFragment.setAdapter(adapter);
    }
}
```

`BrowseFragment#onCreateView`が呼ばれた時点で`mAdapter`が設定済みの場合はそこで`mAdapter`が`mHeadersFragment`に設定されます。また、`BrowseFragment#setAdapter`が呼ばれた時点で`BrowseFragment#getView`が`null`以外を返せば、その場合も設定されます。
`BrowseFragment`を継承したクラスで、`super.onCreateView`の後で、かつ`this.onCreateView`が終了していないタイミングで`BrowseFragment#setAdapter`を呼ぶと、変数`mAdapter`に代入されるだけで、実際にデータを表示するための手続きが実行されません。

### 対応内容
`BrowseFragment#setAdapter`の実行タイミングを変更しました。

```java:MyBrowseFragment.java
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);

    mAdapter = new new ArrayObjectAdapter(new ListRowPresenter());
    setAdapter(mAdapter);
}
```


## PlaybackOverlayFragment
### 変更された点
デフォルトの`paddingTop`がめっちゃでかくなってました。

```xml:values.xml
<dimen name="lb_playback_controls_padding_top">216dp</dimen>
```

TVの画面高さが540dpなので、デフォルトだと上2/5が`padding`領域になりますね。

### 対応内容
私の場合は画面全体にコンテンツを描画するような画面でしたので、適切な値で`paddingTop`を上書きました。


## GuidedStepFragment
### 変更された点
レイアウトが大幅に変更されました。

23系
![guided-step-23.png](https://qiita-image-store.s3.amazonaws.com/0/61666/56084c4e-03d8-ae70-9544-d6678b99ae23.png)

25系
![guided-step-25.png](https://qiita-image-store.s3.amazonaws.com/0/61666/3c6b1a99-3979-5ccc-e9eb-181612f70b96.png)

デフォルトの`GuidanceStylist.Guidance`を使っていれば特に問題ないと思いますが、レイアウトを自作して`GuidanceStylist`を独自実装しているような場合は一度見直しが必要になりそうです。

### 対応内容
自作レイアウトけっこう使ってたのですが、今後こんな感じで変更されると厳しいので基本デフォルトで実装するようにしました。


## TitleView
### 変更された点
23時点では`TitleView`クラスの型として扱われていたため、カスタマイズする場合は`TitleView`を継承しなければいけませんでしたが、25では`TitleViewAdapter.Provider`インターフェースを実装した`View`として扱うようになったため、カスタマイズの自由度が上がりました。
`TitleViewAdapter.Provider`は以下で定義されており、これまで`TitleView`が直接操作を受け付けていた各メソッドを、`TitleViewAdapter`が担保する形になっています。

```java:TitleViewAdapter.java

public abstract class TitleViewAdapter {

    public interface Provider {
        TitleViewAdapter getTitleViewAdapter();
    }
}
```

`TitleView`として扱いたい`View`で`getTitleViewAdapter()`を実装し、そのメソッドで返す`TitleViewAdapter`を継承した実装クラスで、`TitleView`として扱う`View`に対するアクセッサを実装します。

また、以前は`TitleView`の取得や設定に使用するメソッドが、`BrandedFragment`内で`package private`なメソッドとして定義されていたため、自作アプリから`TitleView`にアクセスするためにはrefrection使ったりゴニョゴニョしたりする必要があったのですが、上記の`TitleView`の定義変更と同時にこれらのアクセッサメソッドも`public`になっていますので、全体的に`TitleView`カスタマイズの自由度が向上しています。

### 対応内容
`TitleView`を継承して自作`TitleView`を使用していたのですが（検索ボタン部分に別コンテンツを表示するような要件があったので）、全体的に書き直しました。これは本家`TitleView`のコードを参考にすれば特に問題はないと思います。

旧実装

```java:MyTitleView.java
public class MyTitleView extends TitleView {

    // TitleViewの各メソッドをオーバーライドする。
    // 独自レイアウトを組む場合は、一度TitleViewでinflateされたViewを破棄する必要があった。
}
```

25での実装

```java:MyTitleView.java
public class MyTitleView extends FrameLayout implements TitleViewAdapter.Provider {

    // TitleViewの操作はTitleViewAdapterを介して行う
    private final TitleViewAdapter mTitleViewAdapter = new TitleViewAdapter() {
        // TitleViewAdapterの各メソッドを実装して、MyTitleViewのリソースにアクセスする
    }
    
    // TitleViewAdapter.Providerで実装が必要なメソッドは以下のひとつだけ。
    @Override
    public TitleViewAdapter getTitleViewAdapter() {
        return mTitleViewAdapter;
    }
}
```

# おわりに
こんな感じで更新対応を行いました。Leanbackは比較的新しめのライブラリなので、メジャーバージョンが上がるとpublicなメソッドもいろいろ変更されてて厳しいですね。こまめに更新対応していこうと思います。
Android TVの記事は需要がほとんど感じられないのでアレですがｗ、もし同じように困ってる方の助けになれば幸いです。

みんなTVアプリ作ろうよ！