nashcft's blog

時々何か書く。

Android project を Gradle 7.0 にしたら JitPack で publish できなくなった

tl;dr

発見

私が作ってる cast-sdk-ktx は JitPack を使ってるのだけど、数日前の Cast SDK のバージョン更新に対応して、ついでに色々依存をアップデートしたのをリリースしようとしたら publish でこけてしまった。
ローカルで build する分には問題なかったので何でだろーって build log を見たところ以下のような log が出てた:

> Configure project :
Gradle version Gradle 7.0

FAILURE: Build failed with an exception.

* Where:
Script '/script/maven-plugin.gradle' line: 2

* What went wrong:
A problem occurred evaluating script.
> Failed to apply plugin 'com.github.dcendents.android-maven'.
   > Could not create plugin of type 'AndroidMavenPlugin'.
      > Could not generate a decorated class for type AndroidMavenPlugin.
         > org/gradle/api/publication/maven/internal/MavenPomMetaInfoProvider

com.github.dcendents.android-maven が原因とのこと。

調査

JitPack では publish のための build の際、project の gradle tasks を調べ、 publish に必要な task が存在しなかった場合にそれを追加するために plugin を導入する。
例として cast-sdk-ktx:0.1.0 の build log を見てみると、以下のような出力が見られる:

...

Getting tasks: ./gradlew tasks --all
Picked up JAVA_TOOL_OPTIONS: -Dfile.encoding=UTF-8 -Dhttps.protocols=TLSv1.2
Tasks: 

WARNING:
Gradle 'install' task not found. Please add the 'maven' or 'android-maven' plugin.
See the documentation and examples: https://jitpack.io/docs/

Adding android plugin
Adding maven plugin
Found android library build file in cast-ktx
Found android library build file in cast-framework-ktx
Found android library build file in cast-tv-ktx
Running: ./gradlew clean -Pgroup=com.github.nashcft -Pversion=0.1.0 install
...

https://jitpack.io/com/github/nashcft/cast-sdk-ktx/0.1.0/build.log

上の抜粋では install task が見つからないことを理由に plugin を導入すると言われている。この際に /script/maven-plugin.gradle の apply が追記されて、その中に com.github.dcendents.android-maven を使った publication の設定が記述されているのだろう。

今回はこの plugin の apply 中にエラーが発生しているので、原因は android-maven plugin 中の実装と考えられる。Build script の実行時に plugin のエラーが起こったという状況なので関連するこちら側の変更は Gradle のバージョンだろう。Build が成功している 0.1.0 と今回エラーが発生した commit では Gradle が 6.8.3 から 7.0 に上がっている。

ところでこの com.github.dcendents.android-maven だが、既に役割を終えているためにメンテナンスが止まっており、 repository も archive されている。

github.com

というわけで今回の問題が修正されることはないし、じゃあこれ以上は詮無しということで原因を追いかけるのも終わり。きっと "Could not generate a decorated class for type" でググれば出てくるだろう。

対処

上述のように publication のための plugin が project に入ってないと com.github.dcendents.android-maven を使われてしまうので、project に publication のための設定を追加することでこの問題を回避できる。
Android Maven Plugin の README でも言及があるが、 AGP 3.6.0 以上では Gradle の Maven Publish Plugin が使えるので、それを apply して設定を追記すれば publication ができるようになる。

developer.android.com

JitPack の GitHub organization にある Android の sample repo を見た感じ最低限の設定だけ書けば動きそう。

https://github.com/jitpack/android-example/blob/19d4ad07071b410dce8bccde0727fd159327db93/library/build.gradle

というわけで cast-sdk-ktx にも Maven Publish Plugin を導入する pull request を作った。Publication の設定にどんなのがあるか色々眺めながら書いてたので JitPack の example より記述量が多くなっている。

github.com

これを書くことになると JitPack のいいところだったお手軽 publish の度合いが下がるので、もうちょっと頑張って Maven Central に移行してしまってもいいのでは...? と思わないこともない。

Jetpack ViewModel はいつ clear されるか

tl;dr

onPause
↓
onStop
↓
(onDestroyView)
↓
onCleared
↓
onDestroy

Jetpack ViewModel のドキュメント には Activity と紐つけた際の owner の lifecycle event と ViewModel の生存期間の関係についての図が載っている。

f:id:nashcft:20210307164204p:plain
https://developer.android.com/topic/libraries/architecture/viewmodel#lifecycle より引用

この図では ViewModel#onCleared の実行は Activity#onDestroy 実行より後のタイミングで起こるように描かれており、そのイメージから ViewModel 登場以来ずっと ViewModel#onClearedonDestroy より後に実行されるものと思っていたが、現在のコードを読んでみると実際はそうではなく onDestroy() の前に実行されることに気がついた。この記事では ViewModel 破棄の実際の流れを追っていく。適当な ViewModel の onCleared() が呼ばれる時の stacktrace を見ればわかる話なので、それで十分という人はここで読むのをやめて実際にそちらを見にいくと良い。

この記事の内容は以下の version で動作を確認している:

  • androidx.activity: 1.0.0-alpha01 および 1.2.0
  • androidx.fragment: 1.1.0-alpha01 および 1.3.0

確認に使ったコードの repository:

github.com

ViewModel が破棄される仕組み

ViewModel は生成時に ViewModelStore に登録される。この ViewModelStore は ViewModel を HashMap で保持しており、 ViewModelStore#clear が呼ばれた時に保持している ViewModel の clear() を実行し、保持している ViewModel を破棄する。 ViewModel#onClearedViewModel#clear 内で実行される。保持している ViewModel の clear が終わったら、 map を clear して参照を切る。

https://cs.android.com/androidx/platform/frameworks/support/+/2e1f4a8ef6eb4252735ed377398bc56f310406c0:lifecycle/lifecycle-viewmodel/src/main/java/androidx/lifecycle/ViewModelStore.java

https://cs.android.com/androidx/platform/frameworks/support/+/636466bf953fa67e04ba56a6f1c04a8a14220595:lifecycle/lifecycle-viewmodel/src/main/java/androidx/lifecycle/ViewModel.java

ViewModelStore#clear が呼ばれる流れ: Activity 編

Activity は ViewModelStore#clear が呼ばれている箇所から遡って辿るとわかりやすいのでそのように説明する。

Activity が持つ ViewModelStoreclear() は、 androidx.activity.ComponentActivity (以下 ComponentActivity*1 ) の constructor で自身の Lifecycle に対して登録している LifecycleEventObserver の中の1つで呼ばれている。

    public ComponentActivity() {
        // ...
        getLifecycle().addObserver(new LifecycleEventObserver() {
            @Override
            public void onStateChanged(@NonNull LifecycleOwner source,
                    @NonNull Lifecycle.Event event) {
                if (event == Lifecycle.Event.ON_DESTROY) {
                    // Clear out the available context
                    mContextAwareHelper.clearAvailableContext();
                    // And clear the ViewModelStore
                    if (!isChangingConfigurations()) {
                        getViewModelStore().clear();
                    }
                }
            }
        });
        // ...
    }

https://cs.android.com/androidx/platform/frameworks/support/+/6f45d365ff22e50c82467ce0d6208433d5ec19d1:activity/activity/src/main/java/androidx/activity/ComponentActivity.java;l=238-251

上に該当する箇所を抜粋したが、 Lifecycle.Event.ON_DESTROY が通知された際、configuration change によるものではない場合に ViewModelStore#clear が実行されることがわかる。

この ComponentActivity (を継承した Activity) の Lifecycle に対して lifecycle event を送るのは誰かというと、 ReportFragment という event 通知用の内部 class である。

https://cs.android.com/androidx/platform/frameworks/support/+/ec4a052c29df9691bd35a936541e6ab46b97514b:lifecycle/lifecycle-runtime/src/main/java/androidx/lifecycle/ReportFragment.java

ReportFragment は、static method の injectIfNeededIn() で渡した Activity の、 Android Framework の方の FragmentManager にこの Fragment を add することで、 Activity の Lifecycle*2 に対して lifecycle event を通知する機能を持っている。実際の通知の仕組みは API level 29 以上とそれ未満とで異なり、API level 29 以上では通知機能を実装した Application.ActivityLifecycleCallbacks を Activity に登録して、 29 未満では ReportFragment の各 lifecycle の callback method を通して通知を実現している。

    public static void injectIfNeededIn(Activity activity) {
        if (Build.VERSION.SDK_INT >= 29) {
            // On API 29+, we can register for the correct Lifecycle callbacks directly
            LifecycleCallbacks.registerIn(activity);
        }
        // Prior to API 29 and to maintain compatibility with older versions of
        // ProcessLifecycleOwner (which may not be updated when lifecycle-runtime is updated and
        // need to support activities that don't extend from FragmentActivity from support lib),
        // use a framework fragment to get the correct timing of Lifecycle events
        android.app.FragmentManager manager = activity.getFragmentManager();
        if (manager.findFragmentByTag(REPORT_FRAGMENT_TAG) == null) {
            manager.beginTransaction().add(new ReportFragment(), REPORT_FRAGMENT_TAG).commit();
            // Hopefully, we are the first to make a transaction.
            manager.executePendingTransactions();
        }
    }

    // ...

    @RequiresApi(29)
    static class LifecycleCallbacks implements Application.ActivityLifecycleCallbacks {
        // ...
    }

この ReportFragmentLifecycle.Event.ON_DESTROY を Activity に通知するのは ActivityLifecycleCallbacks#onActivityPreDestroyed または ReportFragment#onDestroy である。片方はそのままな名前をしているのでわかったようなものだが、これらが実行されるのは android.app.ActivityperformDestroy 中、前者は dispatchActivityPreDestroyed() で、後者は mFragments.dispatchDestroy() の先であり、 onDestroy の前となる。

    final void performDestroy() {
        dispatchActivityPreDestroyed();  // 筆者註: API >= 29 の場合はここで ViewModel が破棄される
        mDestroyed = true;
        mWindow.destroy();
        mFragments.dispatchDestroy();  // 筆者註: API < 29 の場合はここで ViewModel が破棄される
        onDestroy();
        EventLogTags.writeWmOnDestroyCalled(mIdent, getComponentName().getClassName(),
                "performDestroy");
        mFragments.doLoaderDestroy();
        if (mVoiceInteractor != null) {
            mVoiceInteractor.detachActivity();
        }
        dispatchActivityPostDestroyed();
    }

https://cs.android.com/android/_/android/platform/frameworks/base/+/eff0b0673c1604477b815cf1bcf001b1df56ef75:core/java/android/app/Activity.java;l=8240-8253

ComponentActivityonCreateReportFragment#injectIfNeededIn に自身を渡しており、それによって上記のように Lifecycle に対して event 通知を受け、 finish の際に ON_DESTROY を受け取ったところで ViewModel を破棄している、という流れになる。

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        // ...
        super.onCreate(savedInstanceState);
        mActivityResultRegistry.onRestoreInstanceState(savedInstanceState);
        ReportFragment.injectIfNeededIn(this);
        // ...
    }

https://cs.android.com/androidx/platform/frameworks/support/+/6f45d365ff22e50c82467ce0d6208433d5ec19d1:activity/activity/src/main/java/androidx/activity/ComponentActivity.java;l=288-300

ViewModelStore#clear が呼ばれる流れ: Fragment 編

androidx.fragment.app.Fragment の場合は起点はわかりやすいが辿る経路が長いのと実際に呼ばれてる場所がちょっと不思議なのでとりあえずスタート地点から。

Fragment に紐つく ViewModelStoreclear() が呼ばれるきっかけになるのは FragmentActivity#onDestroy, つまり Fragment#onDestroy が呼ばれるのと同じである。そこから FragmentController -> FragmentManager -> SpecialEffectsController -> FragmentStateManager と進んでいって、この FragmentStateManager#moveToExpectedStateFragmentStateManager#destroy に入る。この経路自体は特に重要ではないので適当に端折ったが、実際に追ってみたい場合は stacktrace を出してそれに沿って読んでみると良い。

この FragmentStateManager#destroy の中、その先で Fragment#onDestroy が実行される mFragment.performDestroy() の直前にある mFragmentStore.getNonConfig().clearNonConfigState(mFragment), ここで Fragment に紐つく ViewModelStore の clear が行われる。

    void destroy() {
        // ...
        boolean beingRemoved = mFragment.mRemoving && !mFragment.isInBackStack();
        boolean shouldDestroy = beingRemoved
                || mFragmentStore.getNonConfig().shouldDestroy(mFragment);
        if (shouldDestroy) {
            // ...
            if (beingRemoved || shouldClear) {
                mFragmentStore.getNonConfig().clearNonConfigState(mFragment);
            }
            mFragment.performDestroy();
            // ...
        } else {
            // ...
        }
    }

https://cs.android.com/androidx/platform/frameworks/support/+/cfc31d1abe7bd6c064a8780add0033853b1a3c91:fragment/fragment/src/main/java/androidx/fragment/app/FragmentStateManager.java;l=747-750

ここで NonConfig と呼ばれる FragmentManagerViewModel は、端的に言うと Activity の ViewModelStore に登録される ViewModel で、それぞれの Fragment に対応する ViewModelStore の管理や Activity の再生成時に retain される Fragment の保持といった役割を持っている。 clearNonConfigState() では、与えられた Fragment に対応する ViewModelStore と子の NonConfig の clear および管理下からの削除を行っている。

    void clearNonConfigState(@NonNull Fragment f) {
        if (FragmentManager.isLoggingEnabled(Log.DEBUG)) {
            Log.d(TAG, "Clearing non-config state for " + f);
        }
        // Clear and remove the Fragment's child non config state
        FragmentManagerViewModel childNonConfig = mChildNonConfigs.get(f.mWho);
        if (childNonConfig != null) {
            childNonConfig.onCleared();
            mChildNonConfigs.remove(f.mWho);
        }
        // Clear and remove the Fragment's ViewModelStore
        ViewModelStore viewModelStore = mViewModelStores.get(f.mWho);
        if (viewModelStore != null) {
            viewModelStore.clear();
            mViewModelStores.remove(f.mWho);
        }
    }

https://cs.android.com/androidx/platform/frameworks/support/+/a48dae920b797f8e4c14aa9c74277fdf85137e76:fragment/fragment/src/main/java/androidx/fragment/app/FragmentManagerViewModel.java;l=186-202

Fragment まわりのコードはややこしくて読むのに苦労するが、とりあえず FragmentStateManager#destroy の中で Fragment#performDestroy (= Fragment#onDestroy の実行) の直前に紐ついてる ViewModel の clear が行われていることがわかった。

雑多な話題

なんでドキュメントは嘘ついてるの?

実装当初は強ち嘘というわけではなかった。もともと ViewModelStore#clearFragmentActivity, Fragment それぞれの onDestroy の中で実行されていて、super#onDestroy を override した method の最後で呼んだ場合は呼ばれる順番として onDestroy -> onCleared と捉えることもできた。

いつから現在の挙動に変わったの?

動作確認している version で察しがつく人もいると思うが、 release version では AndroidX として一番最初である activity:1.0.0-alpha01 / fragment:1.1.0-alpha01 からである。 Commit のレベルでは 2018-10-23 に commit された以下の2つ:

これを挙動の変更と言えるかは難しくて、 Activity のCLにつけられたコメントを見ると、 super.onDestroy() を呼ぶタイミングがアプリ開発者に委ねられてしまっていることを問題視していて、現在のような挙動を常にすることを保証したかったように読み取れる。

Through discussions with adamp@, we wanted to make a firm decision on exactly when this will run as the final LifecycleObserver going out. onDestroy() does not offer the same guarantee since developers can call it at any point in their onDestroy() method.

実際 onPause ~ onDestroy の super method を最初に呼ぶ流派と最後に呼ぶ流派があるようだし*3、またうっかり super.onDestroy() を呼び忘れることによって clear されないということも起こりうるので、フレームワークの仕組みとしては現在の形の方が正しいのかもしれない。そうするとやっぱりドキュメントの図は嘘をついてることになってしまうので直すべきでは? となるが...

書いてて気づいたが Fragment の destroy と Fragment に紐つく ViewModel の clear はまだ FragmentActivity#onDestroy に依存しているので、 super.onDestroy() の呼び忘れで実行されないということがありうる。呼ばれる順序は常に同じにすることができたけど、そもそも呼ばれないという問題はまだ解消できていないようだ。

viewModelScope について

viewModelScopeViewModel#clear 実行時、 ViewModel#onCleared が呼ばれる前に cancel される。

https://cs.android.com/androidx/platform/frameworks/support/+/20d4085eedeb1f83964a802cf6286bfbdba57498:lifecycle/lifecycle-viewmodel-ktx/src/main/java/androidx/lifecycle/ViewModel.kt

FragmentActivityLifecycleRegistry には2回 ON_DESTROY が通知されるけど

上で説明した ComponentActivity 由来の経路と FragmentActivity#onDestroy で実行されている mFragmentLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) で計2回 ON_DESTROYLifecycleRegistry に送られている。

1回目の event 通知時の ViewModelStore#clear で保持していた ViewModel は全て切り離されるので ViewModel#onCleared が2回呼ばれるようなことはない。

まとめ

Activity (API >=29)

ViewModel cleared at com.github.nashcft.app.ActivityViewModel.onCleared(ActivityViewModel.kt:23)
        at androidx.lifecycle.ViewModel.clear(ViewModel.java:138)
        at androidx.lifecycle.ViewModelStore.clear(ViewModelStore.java:62)
        at androidx.activity.ComponentActivity$4.onStateChanged(ComponentActivity.java:261)
        at androidx.lifecycle.LifecycleRegistry$ObserverWithState.dispatchEvent(LifecycleRegistry.java:354)
        at androidx.lifecycle.LifecycleRegistry.backwardPass(LifecycleRegistry.java:284)
        at androidx.lifecycle.LifecycleRegistry.sync(LifecycleRegistry.java:302)
        at androidx.lifecycle.LifecycleRegistry.moveToState(LifecycleRegistry.java:148)
        at androidx.lifecycle.LifecycleRegistry.handleLifecycleEvent(LifecycleRegistry.java:134)
        at androidx.lifecycle.ReportFragment.dispatch(ReportFragment.java:68)
        at androidx.lifecycle.ReportFragment$LifecycleCallbacks.onActivityPreDestroyed(ReportFragment.java:224)
        at android.app.Activity.dispatchActivityPreDestroyed(Activity.java:1498)
        at android.app.Activity.performDestroy(Activity.java:8241)
        at android.app.Instrumentation.callActivityOnDestroy(Instrumentation.java:1344)
        at android.app.ActivityThread.performDestroyActivity(ActivityThread.java:5096)
        at android.app.ActivityThread.handleDestroyActivity(ActivityThread.java:5140)
        at android.app.servertransaction.DestroyActivityItem.execute(DestroyActivityItem.java:44)
        at android.app.servertransaction.TransactionExecutor.executeLifecycleState(TransactionExecutor.java:176)
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:97)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:223)
        at android.app.ActivityThread.main(ActivityThread.java:7656)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)

Activity (API < 29)

ViewModel cleared at com.github.nashcft.app.ActivityViewModel.onCleared(ActivityViewModel.kt:23)
        at androidx.lifecycle.ViewModel.clear(ViewModel.java:138)
        at androidx.lifecycle.ViewModelStore.clear(ViewModelStore.java:62)
        at androidx.activity.ComponentActivity$4.onStateChanged(ComponentActivity.java:261)
        at androidx.lifecycle.LifecycleRegistry$ObserverWithState.dispatchEvent(LifecycleRegistry.java:354)
        at androidx.lifecycle.LifecycleRegistry.backwardPass(LifecycleRegistry.java:284)
        at androidx.lifecycle.LifecycleRegistry.sync(LifecycleRegistry.java:302)
        at androidx.lifecycle.LifecycleRegistry.moveToState(LifecycleRegistry.java:148)
        at androidx.lifecycle.LifecycleRegistry.handleLifecycleEvent(LifecycleRegistry.java:134)
        at androidx.lifecycle.ReportFragment.dispatch(ReportFragment.java:68)
        at androidx.lifecycle.ReportFragment.dispatch(ReportFragment.java:144)
        at androidx.lifecycle.ReportFragment.onDestroy(ReportFragment.java:134)
        at android.app.Fragment.performDestroy(Fragment.java:2782)
        at android.app.FragmentManagerImpl.moveToState(FragmentManager.java:1451)
        at android.app.FragmentManagerImpl.moveFragmentToExpectedState(FragmentManager.java:1576)
        at android.app.FragmentManagerImpl.moveToState(FragmentManager.java:1637)
        at android.app.FragmentManagerImpl.dispatchMoveToState(FragmentManager.java:3046)
        at android.app.FragmentManagerImpl.dispatchDestroy(FragmentManager.java:3026)
        at android.app.FragmentController.dispatchDestroy(FragmentController.java:248)
        at android.app.Activity.performDestroy(Activity.java:7394)
        at android.app.Instrumentation.callActivityOnDestroy(Instrumentation.java:1306)
        at android.app.ActivityThread.performDestroyActivity(ActivityThread.java:4443)
        at android.app.ActivityThread.handleDestroyActivity(ActivityThread.java:4476)
        at android.app.servertransaction.DestroyActivityItem.execute(DestroyActivityItem.java:39)
        at android.app.servertransaction.TransactionExecutor.executeLifecycleState(TransactionExecutor.java:145)
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:70)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1808)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:193)
        at android.app.ActivityThread.main(ActivityThread.java:6669)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:493)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:858)

Fragment

ViewModel cleared at com.github.nashcft.app.FragmentViewModel.onCleared(FragmentViewModel.kt:23)
        at androidx.lifecycle.ViewModel.clear(ViewModel.java:138)
        at androidx.lifecycle.ViewModelStore.clear(ViewModelStore.java:62)
        at androidx.fragment.app.FragmentManagerViewModel.clearNonConfigState(FragmentManagerViewModel.java:199)
        at androidx.fragment.app.FragmentStateManager.destroy(FragmentStateManager.java:769)
        at androidx.fragment.app.FragmentStateManager.moveToExpectedState(FragmentStateManager.java:350)
        at androidx.fragment.app.SpecialEffectsController$FragmentStateManagerOperation.complete(SpecialEffectsController.java:742)
        at androidx.fragment.app.SpecialEffectsController$Operation.cancel(SpecialEffectsController.java:594)
        at androidx.fragment.app.SpecialEffectsController.forceCompleteAllOperations(SpecialEffectsController.java:329)
        at androidx.fragment.app.FragmentManager.dispatchStateChange(FragmentManager.java:3116)
        at androidx.fragment.app.FragmentManager.dispatchDestroy(FragmentManager.java:3091)
        at androidx.fragment.app.FragmentController.dispatchDestroy(FragmentController.java:334)
        at androidx.fragment.app.FragmentActivity.onDestroy(FragmentActivity.java:322)
        at com.github.nashcft.app.MyFragmentActivity.onDestroy(MyFragmentActivity.kt:43)
        at android.app.Activity.performDestroy(Activity.java:8245)
        at android.app.Instrumentation.callActivityOnDestroy(Instrumentation.java:1344)
        at android.app.ActivityThread.performDestroyActivity(ActivityThread.java:5096)
        at android.app.ActivityThread.handleDestroyActivity(ActivityThread.java:5140)
        at android.app.servertransaction.DestroyActivityItem.execute(DestroyActivityItem.java:44)
        at android.app.servertransaction.TransactionExecutor.executeLifecycleState(TransactionExecutor.java:176)
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:97)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:223)
        at android.app.ActivityThread.main(ActivityThread.java:7656)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)

*1:余談だが androidx.core にも ComponentActivity が存在し、 androidx.activity.ComponentActivity の直接の親 class となっている

*2:実際には getLifecycle() で取得できる Lifecycle が LifecycleRegistry である必要がある

*3:要出典

実装の詳細をテストすることについての所感

下書きにずっと残ってたので供養。本当は色々書くつもりだったけど量的にも内容的にも一生まとまりそうになかったから自分の主張分だけ少し体裁を整えて出すことにした。

実装の詳細のテストは書いてもいい

ただし成果物をコミットするまでには消しておいた方がいい。

テストの目的や内容によって寿命が異なることをを意識しよう

  • 寿命が短い
    • 実装している自分のためのテスト
      • 意図通りに動いている?
      • 自分の実装に自信を持つためのテスト
      • 確認が取れ次第寿命を迎える
  • 寿命が (比較的) 長い
    • 機能単位の振る舞い (仕様) に関して記述されたテスト
      • 要求に対してこの機能が達成したいことを伝える
      • よりユーザとの距離が近いものほど寿命が長くなることが期待できそう
        • e.g.) モジュール > クラス > 関数
        • 逆にユーザに近いもののテストが壊れやすかったら詳細のテストが紛れているサインかも
        • 単に仕様が生煮えなだけかもしれない
      • 仕様が変わった、機能がいらなくなったら寿命を迎える

寿命に応じて適宜テストを削除しよう

残されたテストはそれに意図があると受け取られて消されにくい。そしてその後の実装や開発活動に影響を与える、場合によっては無意味な足枷になりうる (関連: "テストコードの慣性の力")。

既に書いた、もしくはこれから書くテストの目的から寿命を把握して、寿命を迎えたらきちんと消してあげる。テストが書かれてから消えるまでの過程がコミットログに残る形である必要はない。

Software Design 2021年3月号

gihyo.jp

第1特集「Java でもう一度学び直すオブジェクト指向プログラミング」を読むために購入。

Java に限定するならば、自分だったらこちらより "Effective Java" を勧めるかな、導入が丁寧で好印象だったけど本編が荒っぽくて残念という感想。1冊の本と雑誌の1特集を比べるのはフェアではないとは思うけど、紙面が限られているという都合以上に記述や主張に考慮が行き届いていないように感じられ、これからオブジェクト指向プログラミングについて学びたい!という人には視野を狭めてしまう懸念があるなというのが理由。自分の中に解釈があって実践をしている人にとっては議論のネタとしていいかもしれない。

いや Effective Javaオブジェクト指向プログラミングの本ではないじゃん、って言われると思うけど、この特集のトピックは interface と継承、合成なので、それだったら Effective Java で十分カバーできてるなーって思ったので。

www.maruzen-publishing.co.jp

InstantTaskExecutorRule がやってること

社で何これって聞かれてした説明を見えるところに文章で残しておこうと思ったもの。

👀

使い方

class MyTest {

  @get:Rule
  val instantTaskExecutorRule = InstantTaskExecutorRule()

  fun `test your component using AAC library`() {
    // LiveData とか Room とかが絡むテスト
  }
}

予備知識: @Rule, TestRule, TestWatcher について

InstantTaskExecutorRule の中身

小さいクラスなので実際のコードは上記の Code Search 等で見てもらうとして、やってることは以下のとおり:

  • テスト実行前に ArchTaskExecutordelegate を設定する
    • 設定した delegateisMainThread は常に true を返し、 executeOnDiskIOpostToMainThread は与えられた Runnable をそのまま実行する
  • テスト終了時に ArchTaskExecutor に設定した delegate を破棄する

ArchTaskExecutor

https://cs.android.com/androidx/platform/frameworks/support/+/1b2b8e2a1dbb4f524af976c8b3c254d6dee974f9:arch/core/core-runtime/src/main/java/androidx/arch/core/executor/ArchTaskExecutor.java

ArchTaskExecutor は、非同期タスクの実行を担う singleton object で、 LiveDataLifecycleRegistry, RoomDatabase で使われている。Main thread か IO thread でタスクを実行するためのAPI (postToMainThread, executeOnDiskIO) を持ち、またそれらでタスクを実行する Executor を取り出すこともできる。

ArchTaskExecutor にはもう一つ、今実行されている thread が main thread かを判定する isMainThread という API がある。これは main thread 上での実行を要求している API 内部で assertion のために使われ、例えば LiveData では observe するときや setValue の中で呼ばれている。

タスクの実行部分は delegate されており、 InstantTaskExecutorRule が行っているように setDelegate を使って外から設定することができる。ユーザが delegate を与えない場合は DefaultTaskExecutor が使用される。

https://cs.android.com/androidx/platform/frameworks/support/+/1b2b8e2a1dbb4f524af976c8b3c254d6dee974f9:arch/core/core-runtime/src/main/java/androidx/arch/core/executor/DefaultTaskExecutor.java

DefaultTaskExecutorpostToMainThread では、main looper に対する Handler を使ってタスクを main thread に post している。 isMainThread は、 main looper に紐ついてる thread と current thread を比較している。

JUnit local test と main looper

そもそもなんで使う必要があるのかというと、Android アプリとしてコードを実行する時と local test を実行する時の環境の差が関係している。Android アプリとして実行される際は、大雑把に説明すると、アプリのプロセスが起動した後まず ActivityThread で main looper の準備と実行が行われ*1、それから諸々の準備ができた後に我々の書いたアプリケーションコードが動く。しかし local test は JUnit test として実行されるので、Android アプリに関わるセットアップの類は実行されない。これがどう関係するかというと、local test 実行時には main looper のセットアップが行われず Looper#getMainLoopernull のままとなるので、それに依存する Handler や、 main looper をもとに "Android アプリにおける main thread" を判定する処理が機能しなくなってしまう。

class MyTest {

  fun test() {
    val mainLooper = Looper.getMainLooper()  // null

    val livedata = MutableLiveData<Int>()
    livedata.observeForever { println("onChanged") }
    // java.lang.NullPointerException
    //     at androidx.arch.core.executor.DefaultTaskExecutor.isMainThread
    //     at androidx.arch.core.executor.ArchTaskExecutor.isMainThread
    //     at androidx.lifecycle.LiveData.assertMainThread
    //     at androidx.lifecycle.LiveData.observeForever
  }
}

この問題に対しては 1) main looper をテスト実行前に用意する、もしくはあるように偽装する、2) LooperHandler に触れない形に変える、という解決方法を考えることができ、 2) を実現する手段として InstantTaskExecutorRule が用意されている、ということになる。

Robolectric による解決と比較

ところで Robolectric を使っても main looper の問題を解決することは可能である。Robolectric はテスト実行時に Android framework のコードを実行したり APIを偽装してくれたりする。つまり上で挙げた 1) のアプローチを取ることになる。今回のケースでは、テストケース実行時には Looper に main looper が準備された状態になっているため、 ArchTaskExecutorDefaultTaskExecutor を使っても NPE でクラッシュすることはなくなる。

@RunWith(AndroidJUnit4::class)
class MyTestWithRobolectric {

  fun `test with robolectric`() {
    val mainLooper = Looper.getMainLooper()  // non-null value

    val livedata = MutableLiveData<Int>()
    livedata.observeForever { println("onChanged") } // observe できる
  }
}

ただし Robolectric による Android framework 部分のセットアップには少なくとも数秒以上の時間がかかるので、「ユニットテスト」として評価した場合は有意に遅くなる *2。あと、非同期処理の待ち合わせについて何かやってくれるということはないので、例えば postValue している箇所がある場合は特別な考慮が必要になる。

@RunWith(AndroidJUnit4::class)
class MyTestWithRobolectric {

  fun `test with robolectric`() {
    val livedata = MutableLiveData<Int>()
    var count = 0
    
    livedata.observeForever {
      println("onChanged: $it")
      count++
    }

    livedata.value = 1  // テスト実行中に observer に通知が届く = 処理される
    livedata.postValue(2)  // 通知が届かない

    assertThat(count).isEquelTo(2) // Expected:2, Actual:1
  }
}

InstantTaskExecutorRuleArchTaskExecutordelegate を差し替えるだけなので非同期タスク処理の関わる部分がここのみの場合のみ有効だが、その分テストの実行時間への影響は小さい。また ArchTaskExecutor を通して行われる非同期処理もただ同期的に実行するようになるので、待ち合わせについて考慮する必要がなくなる。

class MyTestWithInstantTaskExecutorRule {

  @get:Rule
  val instantTaskExecutorRule = InstantTaskExecutorRule()

  fun `test with InstantTaskExecutorRule`() {
    val livedata = MutableLiveData<Int>()
    var count = 0
    
    livedata.observeForever {
      println("onChanged: $it")
      count++
    }

    livedata.value = 1
    // ArchTaskExecutor#postToMainThead の振る舞いが同期化されるので、
    // postValue もテスト実行時に observer に通知が届くようになる
    livedata.postValue(2)

    assertThat(count).isEquelTo(2) // passed
  }
}

使い分けとしては, LiveData や Room しか関わらないような local test については InstantTaskExecutorRule だけ使って、他にも Android framework への依存があるようなコンポーネントに対する local test では Robolectric も使う、みたいな感じで良いと思う。

まとめ

  • AACコンポーネントのいくつかはスレッド確認や非同期実行を行う際に ArchTaskExecutor を使用している
  • ArchTaskExecutor は振る舞いの実体を delegate しており、デフォルトでは DefaultTaskExecutor を使用している
  • DefaultTaskExecutor は main thread の確認に Looper#getMainLooper を参照している
  • Local test は Android framework に関わるセットアップを行ってくれないため、 main looper の準備も行われない
  • InstantTaskExecutorRuleArchTaskExecutordelegate を差し替えて、実行 thread の確認を何も見ずに答えたり非同期実行を担う箇所を同期的に処理したりするように挙動を変更することで、local test でも AAC 関連の非同期処理や main thread 制約の関わる部分をよしなに動くようにしてくれる

*1:この辺は『Android を支える技術 <I>』の 1.3.2 あたりを読むと良い

*2:Robolectric が「高速」と謳われる文脈は実機や emulator を用いたデバイステストとの比較で、apk をビルドしてデバイスにインストールして実行するのと比べたら当然高速

2020年振り返り

去年もやってたし今年も。

転職

nashcft.hatenablog.com

前職を辞めた理由の9割は前職全く関係ないし、残りの1割も (会社側にとって) 殆どとばっちりのようなものなので、大変な時期に突然辞めるねってなってしまったことは申し訳なく思う。一方転職後は割と当初の思惑通りに過ごすことができたし、それが自分にとってプラスに働いてるように感じるので、振り返ってみると良い判断だったという自己評価にはなる。

現職は入社する前からリモートワークに移行してて、私も入社してからずっとフルリモートで働いている。オフィスには3, 4回しか行ってない。主にやってたことは以下の3点か。他は細々とした機能開発をしたりいつも通りテスト書いてリファクタリングしてバグを直したりみたいな。

  • 出てってから戻ってくるまでの間に荒廃したCIの立て直し
  • Google Cast 機能関連の開発
  • メンバーのメンタリング

1つ目のCIの立て直しは、まあ反省するところもあって、以前いた時はずっと自分だけでCIをメンテしていて他のメンバーに触ってもらう機会を作っていなかったので、仕組みの周知や自分たちでメンテするものという意識づけができておらず、私がいなかった間は勝手のわからないオーパーツみたいな感じになっていたのが荒廃の原因だったのだと思う。立て直しも GitHub Actions 移行も兼ねて結局1人でやってしまったけど、今は幸いなことに興味を持ってくれたメンバーがいるので、試しに job を作ってもらうなどしてもらっている段階。

Cast は夏ぐらいからやってて、今月で一区切りついたと思う。元々別のメンバーが1人でやっていたのだけど他に触れる人がいない状況になっていたのでお手伝いから始めて、今は開発を分担できるレベルになったところ。Cast は情報が公式 docs くらいしかなく、それもあまり親切な記述ではないところもあって勘違いに苦しめられることが多々あった。

3つ目、チームメンバーの何人かと個人的に 1on1 のようなことをしている。きっかけは仕事の調子が悪そうなのだけど特にヘルプを求めてくるでもないメンバーがいたのでつついてみたところ、しっかり抱え込んでいたというところから。その場でいくらか話を聞いた感じすぐに解決できるようではなかったので、 1on1 の形を借りて定期的に相談を聞きつつ悩み事を整理してもらう場を設けて今まで続けており、当人曰く状況が好転したとのこと。今はもう1人新卒入社のメンバーとも 1on1 をしている。こちらは始めたばかり (きっかけの人は前いた時に一緒に働いてた) なのと、今年転職してからはじめましてのメンバーなので、まだお互いのキャラクターを掴むために雑談中心でやっている。最初に書いたように組織的なものではなく個人で勝手にやっていることなので、チームの中でも関わってない人もいる。なので、この活動がチームの関係性に影響を与える、端的にいえば派閥を形成することのないように注意はしているが、中々バランスが難しい。じゃあチームでの活動にすればいいじゃんという意見もあるだろうが、そういうピープルマネジメントをしたくないからウチでエンジニアやっとるんじゃという人もいるので...

引越し

去年の振り返りを読んだら2020年にやりたいことだった。実現できて良かったですね。

転職してすぐくらいから物件を探したり問い合わせをしたりして、実際に引越したのは11月という感じ。今年の半分以上引越しのことを考えていた気がする。フルリモートになって通勤を考慮しなくて良くなったので、部屋の広さの他に場所のために妥協していた楽器演奏ができる物件という条件で探して、最終的に都心からはやや離れたところにある24時間演奏可能な防音室つき物件に入居することができた。

楽器を弾くなら自宅でなくともカラオケやスタジオに行って弾くことも可能ではあるけど、これまでそうしようと思ってもできなかったことから私が楽器を触りたいと思ったときに触れる状態にすべきと考えたので、部屋の広さや設備を多少犠牲にすることは許容しようということになり、結果としてキッチンとリビングの設備が犠牲になった。

そんなこんなで楽器を再開することになったが、長年放置していた楽器のメンテをどうするかというのが直近の悩み事。

リモートワーク

働きすぎるのをどうにかしたい。オフィスで働いてた頃よりもモニタに張り付いてる時間が長い。

暮らし

元々外出は多くない方だったけど、輪にかけて外出をしなくなった。私は家に籠もりきりな生活でも特に問題はないと感じているのだが、展示会とか演奏会とかに行かなくなって文化の摂取量が減ったなあと思うことがあり、何かしらネガティブに働いているかも知れない。

運動量は減った。リングフィットアドベンチャー手に入れられなかった勢なので。

買ったもの

引越してからは色々家具家電を新調した、またする予定。

例えば仕事場デスクは FLEXISPOT のスタンディングデスクにした。

nashcft.hatenablog.com

この記事を書いた後にモニタとしてDELL の U3219Qを1枚、あとデスクマットを買うなどした。モニタはもう1枚あってもいいかもと思っているが、どっちかというとTV系端末の検証用モニタとして小さめのを買った方がいいかも知れないなという感じ。

あとは部屋が増えたり広くなったりしたので加湿器や空気清浄機も新しいものを買い足したり、食洗機を導入したりした。他にも色々買う予定のものがあるのだけど、思いの外引越しが遅くなってしまいすぐに年末になったので、大きいものなんかは年が明けてからでいいやということになった。

OSS活動

今年一番印象に残ってるのは Android アプリ開発をやるようになってから初めて DroidKaigi のアプリに contribute したことか。

nashcft.hatenablog.com

あと Groupie に ViewBinding のサポートとその他いくつか小さなPRを送った。

nashcft.hatenablog.com

現在は転職して Groupie を使わなくなってしまったので repository を眺めるくらいしかしてないけど、あれからまた活動が止まって、息を吹き返したかと思ったらまたメンテナが替わり、そしてまた動きがあるようなないような感じで、先行きが不安な状態はまだしばらく続きそう。

github.com

また、Google Cast まわりを触っていたときに SDKktx library を作って公開した。

nashcft.hatenablog.com

上の記事を書いて以降 play-services-cast-tvAPIを触る機会があったのだけど、感想として拡張関数生やせるといえば生やせるが生やした方がいいかというというと考え込んでしまう、しかしいまのところ作れるところは作るスタンスになってるので気が向いたらやっておくか、という所感。個人的な印象で言うと Cast Connect の実装やってるところ Google 自身以外にどれだけあるの? みたいなところがあるので、 play-services-cast-tv 向けの開発はそんなにモチベーションが上がらない...

2021年にやりたいこと

2021年からもしばらくは自宅にいる時間が長くなると思うので、自宅でできる娯楽や活動を増やせるといいなと思っている。技術的な方面は、何か基礎的なところをしっかりやっていかんとなという所感。本は買うだけ買ってあるのでいい加減積んでるのを崩していきたい。

おわりに

有線のノイズキャンセリングヘッドホンが欲しい。おすすめ教えてください。

Material Components for Android: ボタンテキストの色

前提として以下の記事がある:

y-anz-m.blogspot.com

大雑把にまとめると Material Components for Android を 1.0.0 から 1.1.0 に上げたら AlertDialog のナビゲーションボタンの文字色が変わってて、コードを比較したらデフォルトカラーの参照先が colorAccent から colorPrimary に変わってたよ、というお話。

この記事を読んだあと、なんで変わったんだろうっていうのが気になったので release notes や commit を辿ってみた。ちなみに私は Material Components for Android は雰囲気でしか使ってないし Material Design のことをよく知らない。

変更があったのは 1.1.0-alpha01 で、具体的には 15bf27d の commit。1.1.0-alpha01 の release note を読むと Material Design の color system に合わせた color theming にしたとある。Material Design の Dialogs のページにある Theming の項を見ると、確かに Button Text は Primary となっている。

公式の 1.1.0 リリースのアナウンス記事も読み返した感じだと、1.1.0 以降は Material Components for Android が提供する component や theme は Material Theming に則った attribute の適用をするからね、という風なので、前提知識として Material Design の guideline をおさえておく必要がありそう。

medium.com