Dynamic feature modulesで、画面・機能を動的に配布してみた

App bundleの一部として、Dynamic feature moduleが公開されましたね。
https://developer.android.com/guide/app-bundle/configure

これにより、初回のインストール時のアプリ容量を削減し、利用する直前で追加ダウンロードさせることができます。

## 実現してみること

**※ 6/8追記: 先日、SpeakerDeckが更新されたため、該当アプリは正常に動作しなくなりました。。。 :scream:**
**※ 6/9追記: とりあえず、最新のSpeaker Deckでも表示できるように修正しました。ただ、本家がスマホブラウザでも見やすくなったので、必要性は薄くなった気がします。。。**

[SlideViewer](https://play.google.com/store/apps/details?id=hm.orz.chaos114.android.slideviewer) という、Speaker Deck閲覧アプリがあります。

試しに入れてみたOCR機能があるのですが、NDKを使っていることもあり、まーまーの容量になっていました。

そこで、OCR機能関連のUIと、OCR機能自体を Dynamic feature module として提供してみることにしました。[^1]

[^1]: 実際には、単純にApp bundleにしただけでかなり容量が減ったので、必要性はあまりありませんでした。

### 動作イメージ

[![動作イメージ](http://img.youtube.com/vi/HhotAx-YKx0/0.jpg)](http://www.youtube.com/watch?v=HhotAx-YKx0)

「言語の選択」をタップした際に、動的モジュールをリクエストしています。
ローディング後に表示される「English」や「Japanese」が表示されている画面が、動的にダウンロードしたモジュールに含まれる画面です。
最後に、画面下部が「解析中」から「Speaker Deck」になっているのが、動的にダウンロードしたモジュールでOCRしたものです。

https://play.google.com/store/apps/details?id=hm.orz.chaos114.android.slideviewer にて、対応したアプリを公開しているので、気になった方はインストールして動かしてみていただければと思います。（宣伝）

私の対応したときの差分は https://github.com/noboru-i/SlideViewer/pull/99 にまとまっています。（苦労の跡が見て取れるかと。。。

### 構成イメージ

![image.png](https://qiita-image-store.s3.amazonaws.com/0/8708/c6dafab8-ce06-6951-c7ee-0bf7102d143a.png)

```
@startuml
rectangle "app module" {
  (SlideListActivity) -> (SlideActivity)
  (SlideActivity) --> (SettingActivity)
}
rectangle "ocr module" {
  (SettingActivity) -> (SelectOcrLanguageActivity)
  (SlideActivity) .> (OcrRecognizerImpl)
}
@enduml
```

SelectOcrLanguageActivity という画面と、 OcrRecognizerImpl という機能を別モジュールとして切り出します。

ただ、画面については https://developer.android.com/guide/app-bundle/configure のドキュメントと、
https://github.com/googlesamples/android-dynamic-features のサンプルを見たら問題なく対応できるかと思います。

実装方法に悩んだ、"機能"の切り出し方法について、一つの方法を紹介しようと思います。

## 前提

Android Studio 3.2 Canary 16で確認しています。

## 実装

### 準備

モジュールは切り出していたんですが、依存関係が逆でした。

通常はベースモジュールから機能を呼び出すため、ベースモジュールが機能を提供するモジュールに依存するかと思います。
ただ、Dynamic feature module では、動的にダウンロードさせるモジュールが、ベースモジュールに依存します。

そのため、画面の方はActivityのFQCN指定で `Intent().setClassName(packageName, className)` といった形で呼び出せます。
クラスを直接利用する場合はインスタンス化してメソッド呼び出しする必要があります。

とりあえず、下記をocr moduleに移動していきました。

- Activity継承クラス
- layoutファイル
- 言語リソース（layoutファイルで利用するもの）
- 機能クラス

※ layoutや言語リソースを移動し忘れて、正体不明のエラーに遭遇しました。最初から別モジュールで実装してたら起こらないと思いますが、既存機能を切り出す際には気をつけたほうが良さそうです。

AndroidManifest.xmlやbuild.gradleも、ドキュメントを参考に修正します。

### 実装

クラス図にすると、下記のようなイメージです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/8708/ef72c743-5a41-5851-b2b0-205472b380f7.png)

```
@startuml

package "app module" {
  class SlideActivity
  interface OcrRecognizer
  OcrRecognizer : recognize()
  OcrRecognizer : listen()
  class "OcrModule(Dagger)" as OcrModule
}

package "ocr module" {
  class OcrRecognizerImpl
}
SlideActivity --> OcrRecognizer
OcrRecognizer <|. OcrRecognizerImpl
OcrModule - SlideActivity
OcrModule o-- OcrRecognizer
OcrModule o-- OcrRecognizerImpl
@enduml
```

app module側に、interfaceだけ定義しておきます。

Dagger2でActivityにInjectしているのですが、その際にreflectionでインスタンスを試みて、だめだったら空実装を返しています。

```OcrModule.java
@Module
class OcrModule {
    @Singleton
    @Provides
    fun provideOcrUtil(app: Application): OcrRecognizer {
        try {
            val clazz = Class.forName("hm.orz.chaos114.android.slideviewer.ocr.OcrRecognizerImpl")
            val constructor = clazz.getConstructor(Context::class.java)
            return constructor.newInstance(app) as OcrRecognizer
        } catch (e: Exception) {
            Log.d("OcrModule", "OcrModule cannot load.", e)
            // return blank
            return object : OcrRecognizer(app) {
                override fun recognize(url: String, bitmap: Bitmap) {
                    // no-op
                }

                override fun listen(): Observable<OcrResult> {
                    return Observable.empty()
                }
            }
        }
    }
}
```

あとは、Dynamic feature moduleのインストール状況が変わったことを検知して、ProcessPhoenixによるプロセスの再起動を行います。

```SlideActivity.java
    override fun onResume() {
        super.onResume()

        val splitInstallManager = SplitInstallManagerFactory.create(this);
        val isInstalled = splitInstallManager.installedModules.contains("ocr")
        isOcrModuleInstalled?.let {
            if (isOcrModuleInstalled != isInstalled) {
                ProcessPhoenix.triggerRebirth(this, intent);
                return
            }
        }
        isOcrModuleInstalled = isInstalled
    }
```

※本当は、プロセス再起動ではなく、Injectのし直しでいいはずなのですが、NDKの再読込がうまく行かなかったりしたので、諦めてプロセスごと再起動しました。。。

プロセスの再起動によって、Dynamic feature moduleの中のものがOcrRecognizerとして返却されるので、OCR機能を提供できるようになりました。

## その他

### keystoreのはなし

debug環境もDeployGateなどで配信する場合、debug環境でもsigningConfigsを設定しているかと思います。
Dynamic feature module側も、同様の設定が必要になります。
サンプルアプリだと全然設定してなかったため、Android Studioから実行しようとしたときに下記のようなエラーが表示され、インストールできませんでした。

> INSTALL_FAILED_INVALID_APK

![image.png](https://qiita-image-store.s3.amazonaws.com/0/8708/d04fe8af-29a6-26d1-f627-c03f4c75d220.png)

これは、2つのapkをインストールしようとしているのですが、それぞれのkeystoreが違っているため発生しているようです。

### コマンドでのdebugビルド・インストール方法

```sh
./gradlew assembleDebug; adb install-multiple app/build/outputs/apk/debug/app-debug.apk modules/ocr/build/outputs/apk/debug/ocr-debug.apk
```

`assembleDebug`を実行すると、それぞれのapkが出力されます。
それを、`adb install-multiple`とすることで、端末に転送できます。

ただ、 SplitInstallStateUpdatedListener の動作テストなどをしたい場合は、Play Consoleでアップロードする必要がありそうです。
"内部テスト"であればすぐ（1, 2分ぐらい？）に配布・確認ができるので、versionCode更新の手間はありますが、それほど苦労はしませんでした。
（fastlaneなどを設定しておけば、コマンド一発でできたかも？）
`./gradlew bundleRelease`の時間は結構長いですが。。。

### moduleの指定方法

https://speakerdeck.com/kgmyshin/multi-module-no-susume の96ページなどを参考に、settings.gradleに下記のように書いていました。

```gradle
include ':app', ':infra', ':ocr'
def moduleDir = new File('modules')

project(':infra').projectDir = new File(moduleDir, 'infra')
project(':ocr').projectDir = new File(moduleDir, 'ocr')
```

ただ、下記のように指定もできるようです。

```gradle
include ':app',
        ':modules:infra',
        ':modules:ocr'
```

個人的には、こっちのほうがスッキリしてて良いような気がしました。（最近できるようになったんですかね？）

### clean直後だとDatabindingがうまく動かない

```
./gradlew clean bundleRelease
```

というコマンドで、aabファイルができるはずなのですが、私のコードでは画面がうまく表示されませんでした。

```
./gradlew clean assembleDebug bundleRelease
```

のように、一旦ビルドを挟むと動いているようですが、いまいち釈然としていません。。。

### beta機能の申請について

Dynamic feature moduleのリリースは、未だベータ機能のため、 http://g.co/play/dynamicdeliverybeta から申請する必要があります。

目処がついたタイミングの5/24の夜に申請を行いました。
自動応答メールも特に無く、なかなか状況が変わらなかったので、6/1の夜に再申請しました。
数日開けて、6/4の夜に内部テストを公開しようとしたら、前まで出ていた警告がなくなり、そのまま申請できるようになっていました。
こういうの、メールでの通知など欲しいですよね。。。（見逃してるだけ。。？