eaglesakuraの技術ブログ

技術的な話題とか、メモとか。

Android定型コードをスッキリさせるための Wofkflow Dispatcher ライブラリ 1.0.0を公開

Wofkflow Dispatcherとは

GitHub - eaglesakura/workflow-dispatcher

  • Androidアプリ開発でよくある 画面やActivityやプロセスを跨ぐ可能性のある非同期処理 をなるべく簡単に扱うためのライブラリ
  • Annotation Processorを使って、定形処理を出力する
    • startActivityForResult/onActivityResultの分岐を書く必要がなくなる
    • Runtime Permissionの処理を書く必要が無くなる
    • DialogFragmentのコールバックに気を使う必要が無くなる
    • 一時的な情報をメンバ変数ではなく引数として扱うことができる
      • 個人的にコレが重要
    • Activity再生成(回転とかLowMemoryとか)しても大丈夫
  • 初めてAnnotation Processorを実装した
    • 以前は否定的だったけど、十分にマシンスペックが上がったので心の中で解禁

似たライブラリ

使うとどう書けるのか

  • 普通に書けるライブラリはいろいろあるが、例えば onActivityResult に戻ってきたときに一時変数の値を使いたい場合は結構気を使う
  • このサンプルでは、ブラウザを開いた時刻を保存しておき、onActivityResultでToast表示に使っている
  • AndroidではActivityやFragmentが再生成される(新しいインスタンスが作られる)場合があるため、save/resotreには気を使う
    • 別Activityで開く -> Runtime Permissionで権限を得る -> ダイアログでYES/NOを質問するとかを順番にやると一時変数が結構増えていく
  • ライブラリが自動的に一時情報を保存・レストアして、コールバック時に渡してくれるのでActivityとかFragmentをクリーンに保てる

Before

class ExampleBeforeActivity : AppCompatActivity() {

    private var tempDialogStartDate: Date? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if(savedInstanceState == null) {
            tempDialogStartDate = startDate
            startActivityForResult(
                Intent(
                    Intent.ACTION_VIEW,
                    Uri.parse("https://google.com")
                ), REQUEST_SHOW_BROWSER
            )
        }
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putSerializable("startDate", tempDialogStartDate)
    }

    override fun onRestoreInstanceState(savedInstanceState: Bundle) {
        super.onRestoreInstanceState(savedInstanceState)
        tempDialogStartDate = savedInstanceState.getSerializable("startDate") as? Date
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        when (requestCode) {
            REQUEST_SHOW_BROWSER -> {
                // read temporary data, clear temporary
                val startDate = tempDialogStartDate!!
                tempDialogStartDate = null

                // show toast
                Toast.makeText(this, "done workflow, startDate='$startDate'", Toast.LENGTH_SHORT)
                    .show()
            }
            else -> super.onActivityResult(requestCode, resultCode, data)
        }
    }

    companion object {
        const val REQUEST_SHOW_BROWSER = 0x0011
    }
}

After

class ExampleAfterActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if(savedInstanceState == null) {
            // show browser
            // ExampleAfterActivity.showWebsite() is generated by kapt(workflow-dispatcher-processor).
            // auto save/restore/clear `startDate` state.
            showWebsite(
                Intent(Intent.ACTION_VIEW,Uri.parse("https://google.com")),
                startDate = Date()
            )
        }
    }

    @OnActivityResultFlow("showWebsite")
    fun onShowWebsiteResult(result: ActivityResult, startDate: Date) {
        // show toast
        Toast.makeText(this, "startDate='$startDate'", Toast.LENGTH_SHORT).show()
    }
}

どうやって実現しているのか

  • startActivityとかするタイミングで、ライブラリが WorkflowProviderFragment をchildFragmentManagerに差し込む
  • WorkflowProviderFragmentの中でstartActivityとかpermissionとか処理をして、結果をコールバックしている
  • 一時データの保存もWorkflowProviderFragmentの中に持っているViewModelに持たせているので、呼び出し側を汚染しない

保存できるステート

  • 生成されるコードを見るとわかるが、内部的にはBundleに突っ込んでいるので、Bundleに保存可能な値であれば @OnActivityResultFlow の引数として受け取ることができる
  • ステートが不要な場合は result: ActivityResult とか第1引数だけでメソッドを宣言すれば良い

使い続けられるのか?

  • Jetpack Navigation ライブラリが同じ機能をサポートしたらコイツは役目を終える
  • 公式、ガンバ・・・

秘伝のタレを受け取ったときにまずやること

秘伝のタレとは

  • 年単位で職人によって継ぎ足され、熟成されたソースである
  • 衛生環境が良い秘伝のタレは素晴らしい味だが、常に良い衛生環境の中で育つわけではない

深呼吸する

  • 何事も、一歩ずつ解決しなければならない

コードフォーマッタを適用する

  • 最初にやる
  • フォーマッタが適用されてないことは多い
  • これを最初にやらないと、Intellij等の自動フォーマッタが意図しないタイミングで走ったときにマージ地獄に陥る
    • 手癖でFormatコマンドを実行する人は要注意だ
    • 普通はそんな注意しなくていいんだけど

sdkVersionを更新する

  • 様々な事情により(大抵はAndroid OSの破壊的変更の影響を回避するため)、compileSdkVersionが古い場合がある
  • しかしGoogle Playは無慈悲で、新しいバージョンでcompileしないとアップロードすら行えない場合がある
  • 更新してあげよう

dependenciesを更新する

JavaをKotlinに変換する

  • 我慢しよう
  • 一気にやるとどこかでミスる上に差分管理が断絶するので、ここは慎重に行おう
  • 開発範囲内で、ちょっとずつやれるところからやっていく
  • 戒めである

CIを組む

  • やれたらやったほうが良い
  • ソースコードは引き継いてでも、CIは引き継げないことも多い(例えば元がオンプレJenkinsとか、無理でしょ)

覚悟を決めて、 abortOnError false を外す

  • abortOnError false のコードは、すべてレガシーコードである
  • lint解決に時間をかけなかった、かける価値を感じなかった、やり方を知らなかった、そういった積み重ねの産物である
  • これの有無を見るだけで、秘伝のタレの深みがわかるであろう
  • 覚悟です。 覚悟ですよ、ナナチ

Android Studio 3.6.1で稀にReleaseビルドが失敗するのを回避する

失敗時のログ

Execution failed for task ':app:minifyReleaseWithR8'.
> Multiple entries with same key: Method $$ServiceLoaderMethods.$load0 Proto L java.util.Iterator =Encoded method Method $$ServiceLoaderMethods.$load0 Proto L java.util.Iterator  and Method $$ServiceLoaderMethods.$load0 Proto L java.util.Iterator =Encoded method Method $$ServiceLoaderMethods.$load0 Proto L java.util.Iterator 

おそらくの理由

何度か実行してみた結果、 --parallel オプションを付けて minifyを有効化する と4〜5回に1回位の割合でビルドに失敗する。 --parallel オプションを消せば再現しなくんった(多分)

CIのリリースビルドの時しか影響がないので、素直に --parallel を外してビルドする。

Issue Tracker

https://issuetracker.google.com/issues/148929520

ワークアラウンドが載っているが、R8バージョンをデフォルトから変更するので選択は慎重に。

branchを切ったらすぐにpushしてCIだけは通しておこう

前提

  • masterやdevelpoブランチでビルドが通っている
  • その状態で作業branchを作成する
  • branchを作ったら、何も更新してなくてもすぐにpushしてCIは通しておく

理由

  • CIが壊れるのは自分の更新だけが理由ではない
  • CI設定の間違い
  • サーバーが更新されて動作しなくなった
  • ライブラリのバージョンがいつの間にか変わっていた
    • dependenciesのバージョン名に + とかつけるのはやめよう
  • キャッシュ有無で正常にCIが通らなくなった
  • mavenリポジトリが死んでしまった
    • bintray、おまえよく死ぬな

自分の作業によってCIが壊れたか 環境によって壊れたか の違いをちゃんと切り分けてから作業しないと、無駄な確認作業が発生するぞ

Kotlinでinterface定義モジュールと実装モジュールを分離した上で実装を隠蔽する

やりたいこと

  • interface定義と、その実装をなるべくキレイに切り分けたい

インターフェース定義用モジュール

// ":interface-module" に定義を書いていく...
interface AuthService {
  fun login(id: String, password: String)
}

実装用モジュール

// ":impl-module" に実装を書いていく...
internal class AuthServiceImpl {
   override fun login(id: String, password: String) {
       // ログイン処理する....
   }
}

コレの問題点

  • AuthServiceImplがinternalなので、他のモジュールから見えない
  • けれどAuthServiceImplをモジュールの外に開放したくない(後で入替えたりとかやりやすいように)

解決

  • interface側にcompanion objectを入れておく

interface定義を修正

// companionだけ書いておく
interface AuthService {
  fun login(id: String, password: String)

  companion object
}

実装用モジュールにExtensionも定義

// ":impl-module" に実装を書いていく...
internal class AuthServiceImpl {
   override fun login(id: String, password: String) {
       // ログイン処理する....
   }
}

// CompanionのExtensionとしてインスタンス生成メソッドを生やす
// これはinternalにしない。
// 利用側からは `AuthService.newInstance()` と呼ぶことができる
fun AuthService.Companion.newInstance() {
    return AuthServiceImpl()
}

Androidアプリビルドの並列最大化の限界値

ビルド速度に影響する要素

  • CPU論理コア数
  • シングルコアスペック
  • メモリ
  • プロジェクト自体の並列性

gradleのworker設定

  • gradleには --max-workers 設定がある
  • このオプションを闇雲に指定しても意味はない
  • デフォルト値はCPU論理プロセッサー
    public DefaultParallelismConfiguration() {
        maxWorkerCount = Runtime.getRuntime().availableProcessors();
    }

Workerが増える条件

  • 並列可能なタスクが発生すると、Workerが増える
  • Workerは予約されない。なので、 --max-workersオプションをいくら設定しても、不必要なworkerは起動しない
  • mavenのライブラリ取得とか、並列実行しやすいタスクが発生すると一気に増えるのが観測できる
    • ローカルキャッシュを全消しして、--max-workersをやたら増やすと壮観な図を観れる
   // DefaultBuildOperationQueue
    @Override
    public void add(final T operation) {
        lock.lock();
        try {
            if (queueState == QueueState.Done) {
                throw new IllegalStateException("BuildOperationQueue cannot be reused once it has completed.");
            }
            if (queueState == QueueState.Cancelled) {
                return;
            }
            workQueue.add(operation);
            pendingOperations++;
            workAvailable.signalAll();
            if (workerCount == 0 || workerCount < workerLeases.getMaxWorkerCount() - 1) {
                // `getMaxWorkerCount() - 1` because main thread executes work as well. See https://github.com/gradle/gradle/issues/3273
                // TODO This could be more efficient, so that we only start a worker when there are none idle _and_ there is a worker lease available
                executor.execute(new WorkerRunnable());
            }
        } finally {
            lock.unlock();
        }
    }

最終的に収束するWorker数

  • どんなに頑張っても、module同士の依存によって増えるworker数には限度がある
  • その状態ではCPUコア数よりもシングルスレッド性能のほうが重要となる

  • これらのビルド速度は最終的にほぼ同一に落ち着いた

快適性

  • ビルド速度は同一だが、快適性とは別問題
  • 次のマシンは何にするか

Firestoreのローカルエミュレーターは同時にトランザクション接続しすぎるとフリーズする

問題点

  • 表題のまま
  • 100個くらいのUnitTestがgoroutineで2〜3のトランザクションを並列発行した
  • フリーズした

回避