dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

Androidも宣言的UI(が当たり前になりそうな)時代に非宣言的UIライブラリでこの先生きのこるには

f:id:delyumemori:20201221172934p:plain

こんにちは。dely株式会社でAndroidチームのマネージャーをやっているうめもり(Twitter: @kr9ly)です。

この記事は「dely #1 Advent Calendar 2020」の22日目の記事です。

21日目の記事は、kazkobay さんによる「デザイナーからPdMになる時に役に立った本と方法まとめ」でした。エンジニアがPdMになるときにも役に立ちそうな本なので、暇を見つけて読んでみようと思いました。

note.com

「dely #2 Advent Calendar 2020」もありますので、是非そちらもご覧ください。

Jetpack Compose前夜

皆さん、Jetpack Compose使ってますか?直近ですと12/2に1.0.0-alpha08、12/16に1.0.0-alpha09がリリースされ、開発が大分活発なのがうかがえますね。正式版リリースは来年末か再来年でしょうか?さすがにまだalpha版なので、業務で本格導入してる方はほとんどいないのではと思いますが、Reactから始まった宣言的UIの流れがとうとうAndroidにも来るのかという感じですね。

試しに触ってみたところすでになかなかの完成度で、React(+React Hooks)のような宣言的UIで普通にAndroidアプリを作る、という体験が2年後くらいには当たり前になっているような気がしました。

React(VirtualDOM)がUIプログラミングの世界にもたらしたもの

VirtualDOMを用いたReactというフレームワークがWebフロントエンド界隈にもたらしたものがなんだったか、ということはWebで調べればたくさん出てきますが、かいつまんで言えば以下の1点に集約されるでしょうか。

プログラマーをUIコンポーネントの状態の差分管理から解放した

UIコンポーネント(DOM)の状態の差分検知&更新処理をVirtualDOMという仕組みが一手に担うことで、プログラマーは常にアプリケーションの最新の状態を反映するVirtualDOMを構築するコードを書くだけで済むようになりました。その上、VirtualDOMが最小の差分を自動的に検出してUIを更新してくれるため、UIを更新するコストをあまり意識せずともパフォーマンスの高いコードが書くことができるようになった、ということはとても大きいでしょう。(もちろん差分検出処理自体の重さもアプリケーションが大きくなれば意識する必要が出てくることだとは思いますが、ここでは触れないでおきます)

もちろんWebフロントエンドではない世界なのでDOMもVirtualDOMもAndroidの世界には存在しませんが、Jetpack Composeでも同じような仕組みでUIコンポーネントの状態を差分更新することからプログラマーを解放してくれます。UIを組み立てる参照等価な関数を組み合わせるだけで、Jetpack ComposeはUIコンポーネントツリーの差分を検知してUIを更新してくれるので、プログラマーはアプリケーションの状態からUIコンポーネントツリーを組み立てることだけを意識してプログラミングすることができます。

プログラマーをUIの差分更新から解放したことで生じたもう一つ大きな変化は、アプリケーションの状態を一か所に集約しやすくなったことです。状態の差分をプログラマーが管理しなければならなかった時は、前の状態をどこかに持っておいて比較しながらUIを更新する必要があるため、どうしてもアプリケーションの状態がコードの中にバラバラに点在してしまいがちでした。

それを一か所に集約できるようになったため、状態をUIコンポーネントとして組み立てるコードと、集約された状態を更新するコードにアプリケーションコードを明確に分離することが出来るようになりました。集約された状態からUIコンポーネントに状態を反映する仕組みは、Reactでは "single source of truth" という言葉を用いて説明されています( https://ja.reactjs.org/docs/forms.html#controlled-components )。今ではAndroid開発者にもお馴染みだと思いますが、Flux(Redux, etc...)のような単方向データフローアーキテクチャは、この二つのコードが明確に分離しやすくなったからこそ生まれたアーキテクチャです。Fluxのような単方向データフローアーキテクチャーをAndroidアプリに適用しようとする試みは過去色々な場所で行われてきたことだと思いますが、VirtualDOMの仕組みのないAndroidの世界でFluxを実現しようとするのは、どうしても無駄なボイラープレートコードの増加や、暗黙的なアーキテクチャの制約などを生み出してしまいがちで、結果的に大きな生産性の改善には繋がりにくかったのではと思います。

UIコンポーネントの状態の差分管理からの解放と、UIコンポーネントの更新のコードとアプリケーションの状態の更新のコードの明確な分離は、確実にAndroidのプログラミングにおいてもコードの保守性を高め、UIプログラミングの生産性の向上に繋がるはずです。

……Jetpack Composeさえ導入できれば。

だがまだ本格的にJetpack Composeを導入するわけにはいかない我々がこの先生きのこる方法

Jetpack Composeはまだalpha版、下手すれば本格的に導入できるのは2年後でしょう。我々は2年後まで指をくわえて待っているのがよいのでしょうか?一応Androidの世界にもAndroid Data Bindingがあります(Android Data Bindingはプログラマーが状態の差分を意識しなければいけないのは何も変わらないですし、大きなコンパイル時間のペナルティがあるという問題もありますが…)し、今まで通りアプリケーションを作ることは問題なくできます。

しかし、Reactがもたらした

  • UIコンポーネントの状態の差分管理からの解放
  • UIコンポーネントの更新とアプリケーションのデータの更新の責務の明確な分離

については、部分的にその利点を取り入れながらAndroidアプリケーションの作り方をアップデートすることはできます。この記事では、その方法の要点だけ説明したいと思います。

非宣言的UIライブラリでsingle source of truthを実現する

差分更新と一括更新が同一のコードで行うことができるような仕組みを用意する

VirtualDOMのような差分検知&差分更新のライブラリがない世界では、何らかの方法で差分検知&差分更新の仕組みを実現し、UIコンポーネントの差分を管理する仕事からプログラマーを解放する必要があります。クラシルのAndroidアプリでは、以下のようなコードでUIコンポーネントの状態の更新を実現できるような仕組みを作りました。

override fun view(
    context: Context,
    state: RecipeItemState,
    updater: ViewUpdater<BaseBinding>
) {
    ...(省略)...
    updater.update(state.title, state.isPr) { layout, title, isPr ->
        val titleBuilder = SpannableStringBuilder()
        titleBuilder.append(title)
        if (isPr) {
            titleBuilder.setSpan(
                LeadingMarginSpan.Standard(
                    32.dpToPx(context), 0
                ), 0, title.length, 0
            )
        }
        layout.titleLabel.text = titleBuilder
    }

    updater.update(state.thumbnailUri) { layout, uri ->
        layout.image.bind(imageLoaderFactories.thumbnail(uri).build())
    }

    updater.update(
        state.isPremiumRecipe,
        state.isPremiumUnlocked
    ) { layout, isPremiumRecipe, isPremiumUnlocked ->
        layout.premiumLabel.visibility = isPremiumRecipe.visibleOrGone()
        layout.lockedLabel.visibility =
            (isPremiumRecipe && !isPremiumUnlocked).visibleOrGone()
    }

    updater.update(
        state.isFavorite,
        state.id,
        state.favoriteStateUpdatedTimestamp
    ) { layout, isFavorite, _, _ ->
        layout.favoriteButton.isActivated = isFavorite
    }

    updater.update(state.isPr) { layout, isPr ->
        layout.prLabel.root.visibility = isPr.visibleOrGone()
    }
}

updater.update関数にパラメータを渡して、その中でAndroidの通常のViewの操作を使ってUIを更新しています。

最初にこの関数を呼んだ時と、updater.updateに渡したパラメータのいずれかが更新されることでViewが更新される仕組みになっており、プログラマーが差分の粒度を意識する必要はあるのがVirtualDOMを使ったコードとのプログラミングとは違うところですが、最低限一括更新と差分更新の両方がこのコードだけで実現することはできています。

内部処理は結構単純で、以下のようなコードで実現されています。

class DiffDetector {

    private object EndOfState

    private var dirty = false

    private var index = 0

    private var diffBuffer = mutableListOf<Any?>()

    inline fun update(callback: () -> Unit) {
        startUpdate()
        callback()
        finishUpdate()
    }

    fun startUpdate() {
        dirty = false
        index = 0
    }

    fun detect(value: Any?): Boolean {
        if (dirty) {
            append(value)
            return true
        }
        if (index >= diffBuffer.size) {
            append(value)
            return true
        }
        if (diffBuffer[index] === EndOfState) {
            dirty = true
            append(value)
            return true
        }
        if (value == null || value::class.javaPrimitiveType != null) {
            if (value != diffBuffer[index]) {
                append(value)
                return true
            }
        } else {
            if (value !== diffBuffer[index]) {
                append(value)
                return true
            }
        }
        index++
        return false
    }

    fun finishUpdate() {
        append(EndOfState)
    }

    private fun append(value: Any?) {
        if (index < diffBuffer.size) {
            diffBuffer[index] = value
        } else {
            diffBuffer.add(value)
        }
        index++
    }
}

このクラスをラップしたのがViewUpdaterですが、内部的にはパラメータの配列を持っておいて、呼ばれた順にindexを動かしてパラメータを突き合わせているだけです。非プリミティブ型の場合は参照を突き合わせていますが、後述のStateをイミュータブルにすることで特に問題が発生しないようになっているのと、差分の検知処理がパフォーマンス上の問題にならないように気を付けています。

もちろんupdater.update関数をネストされるなどされると状態がおかしくなってしまうのですが、そこはネストした場合に例外を吐くなどの処理を追加することでおかしな呼ばれ方ができないようになっています。

Stateを集約し、イミュータブルにする

クラシルのAndroidアプリでは基本的にコンポーネント(Activity, Fragmentくらいの粒度で管理しています)内では単一のオブジェクトでStateを管理しています。Stateは原則イミュータブルで、State内のフィールドのいずれかが更新されるたびにStateのインスタンスが更新されるような仕組みになっています。

以下はクラシルのmodel関数(コンポーネントの状態を更新するロジックをmodelというfunctionにまとめています)内から一部抜き出したコードですが、基本的にStateはdata classとして定義しており、dispatcher.dispatch内でstateオブジェクトをcopyすることで新しいStateを作るという形で状態を更新しています。単一のオブジェクトであるStateが更新されるたびに、view関数が呼ばれて状態がUIコンポーネントに反映される仕組みになっています。

override fun model(
    action: Action,
    props: Props,
    state: State,
    dispatcher: StateDispatcher<State>
) {
    when (action) {
        is RecipeDetailListSnippet.Actions.UpdateRecipeSummary -> {
            dispatcher.dispatch {
                state.copy(
                    summary = action.summary
                )
            }
        }
        is RecipeDetailListSnippet.Actions.UpdateRecipeDetail -> {
            favoriteFeature.addBrowsingHistory(props.id)
            dispatcher.dispatch {
                state.copy(
                    detail = action.video
                )
            }
        }
        is RecipeDetailListSnippet.Actions.UpdateQuestions -> {
            dispatcher.dispatch {
                state.copy(
                    questions = action.questions
                )
            }
        }
        is RecipeDetailListSnippet.Actions.UpdateTaberepoInfo -> {
            dispatcher.dispatch {
                state.copy(
                    taberepos = action.taberepos,
                    taberepoCount = action.taberepoCount
                )
            }
        }
    }
}

コードの責務を分割し、Stateの更新によらないViewの更新を抹殺する

ここまでのコードで気づいた方もいらっしゃるかもしれませんが、クラシルのUIコンポーネント周りのコードは cycle.js のModel-View-Intentというアーキテクチャからの発想でコードを3つの関数に分割しています。

f:id:delyumemori:20201221171509p:plain

  • Android ViewからActionを発行するintent関数
  • ActionとStateをもとにアプリケーションの状態を更新するmodel関数
  • StateをもとにAndroid Viewを更新するview関数

Intentのコードの雰囲気だけ共有しておくと、こんな感じです。

override fun intent(
    layout: ComponentViewBinding,
    dispatcher: StatelessActionDispatcher<Argument>
) {
    layout.clearButton.setOnClickListener {
        dispatcher.dispatch {
            SearchInputSnippet.Actions.UpdateInputText("")
        }
    }

    layout.voiceButton.setOnClickListener {
        dispatcher.dispatch {
            SearchInputSnippet.Actions.StartVoiceInput
        }
    }

    layout.searchInput.addTextChangedListener { text ->
        if (layout.searchInput.hasFocus()) {
            dispatcher.dispatch {
                SearchInputSnippet.Actions.UpdateInputText(text.toString())
            }
        }
    }
}

責務を3つのコードに分割して、view関数だけでUIの状態を更新するようになっています。原理上はintentでViewを更新できてしまいますが、Actionが受け取れないこと、Stateが参照できないことで通常の用途では更新しにくいことで抑止になっていることと、あとはコードレビューでそういったコードが混入しないように気を付けています。

クラシルのAndroidアプリでは、single source of truthの考え方をこれらの工夫で実現しています。どうしてもAndroid Viewが元々そういった発想で作られていないこともあり、たまにワークアラウンド的なコードが必要になることもありますが(layout.searchInput.hasFocus()はそのワークアラウンドの一部だったりします)、基本的にはすっきりコードを書くことが出来ています。

コンポーネントツリー全体でもsingle source of truthを実現する

クラシルのAndroidアプリではFragmentの採用をやめたのですが、一方でコンポーネントツリーの分割統治はUIアプリケーションを作るうえでコードの再利用性などを考えても避けて通れない問題なので、独自の仕組みでコンポーネントツリーを分割統治できるようにしています。その詳細については今回は触れませんが、Fragmentなどを採用する場合でも考え方は一緒だと思いますので参考になればと思います。

ReactのパラメータがState/Propsに分かれている理由

クラシルのAndroidアプリを今のアーキテクチャにした際に、最初は全て単一のStateで作っていたのですが、途中からReactのように内部でプライベートで持つ状態をState、外部から渡される内部からは変更できない状態をPropsと分けて管理するようになりました。最初はなんらかのパラメータを外から渡してStateを更新する仕組みだったのですが、例えばディープリンクなどの処理によって親からコンポーネントの状態を変更することが発生すると、Stateと分離してPropsという状態を導入したほうが都合がいいことが分かったためです。

具体的には、親から渡されたパラメータをStateに反映し、かつ内部でそのパラメータを変更する場合に問題が発生しました。内部で親から渡されたパラメータを更新するようになると、上のコンポーネントはその変更を把握していないため、おかしなタイミングでStateが親から渡されるパラメータで上書きされてしまうという問題が発生しました。結局内部のパラメータを変更したいタイミングで上のコンポーネントに対してActionを投げるようにする、という形で対応したのですが、それは結局Propsという内部から変更不能なパラメータでUIの状態を更新しているのと同じことだということに後で気づき、その形に落ち着きました。

"一つ上のComponent"からStateを更新することを意識する

State/Propsの仕組みにアーキテクチャを移行する際に、コンポーネントツリー全体でsingle source of truthを実現する際に意識すべきことが一つあることがわかりました。それは、

親が知っているべきことは必ず親がStateを持ち更新すること

ということです。single source of truthに則ったアーキテクチャを採用すると、基本的には親から子供のコンポーネントに状態を問い合わせることがなくなるため、自然にそういった考え方になっていくのではないかとは思いますが。

限界はある、やりすぎないことも大事

今回クラシルのAndroidアプリのアーキテクチャについて全てのことをご紹介できたわけではないですが、以上のような工夫で宣言的UI時代も戦えるようなアーキテクチャにアップデートをはかっています。今後も生産性が高く、素早くユーザーに価値を提供できるアーキテクチャを維持していきたいですね。

とはいえ、基本的には非宣言的UIライブラリでやっていることなので、限界やちょっとした癖もあります。例えばRecyclerViewのスクロールのようなStateで管理すると困るような状態もあったり、アーキテクチャの統一感を意識しすぎると無理が出てくるような局面もあります。

95%位のユースケースは自然に書けるが、いざという時の5%くらいの抜け道は残しておくという発想で、開発に著しい無理が出ないような形を保っていきたいですね。

We're Hiring!

クラシルのAndroidチームでは、生産性の高いアーキテクチャを維持・アップデートしつつも楽しくプロダクト開発できる人、したい人を求めています。今回の記事を読んで面白そうだな、もっと話を聞いてみたいなと思った人、クラシルの開発に興味が出てきた人、是非お話ししましょう。クラシルの採用に興味なくても、記事が面白いと思ってくれた人とは是非お話してみたいのでTwitterなどで連絡いただけると嬉しいです。

f:id:delyumemori:20201221172144p:plain
きのこる先生