通常、状態管理はUIアプリケーションの最も複雑な部分です。 Instacartでは、数年前からRxJavaを利用して状態を管理しています。 Androidチームが拡大するにつれて、新しい開発者が始めるのに急激な学習曲線(learning curve)があることに気付きました。 そして複雑さを軽減する方法を実験し始めました。 数回のテストの後、私たちはKotlinで構築されたオープンソースの状態管理ライブラリであるFormulaを発表できることを嬉しく思います。 Formulaは、アプリの複雑さを管理する、シンプルで宣言的で構成可能なAPIを提供します。 目標は、多くの式を行わずにプログラムの本質を表現することです。

シンプルなアプリ構築する

おそらく、Formulaがどのように機能するかを示す最も簡単な方法は、単純なストップウォッチアプリケーションを構築することです。 ストップウォッチの実行時間を表示し、ユーザーがストップウォッチを開始/停止またはリセットできるようにします。

Formulaを利用する場合、推奨される方法は、UIに表示する必要があるものと、ユーザーが実行できるアクションを定義することです。 これをKotlinデータクラスでモデル化し、この概念をRenderModelと呼びます。 ユーザーアクションをレンダーモデルのイベントリスナーとしてモデル化します。

data class StopwatchRenderModel(
    val timePassed: String,
    val startStopButton: ButtonRenderModel,
    val resetButton: ButtonRenderModel
)

data class ButtonRenderModel(
    val text: String,
    val onSelected: () -> Unit
)

レンダリングモデルは、ビューの不変(immutable)の表現です。 UIを更新するたびに、新しいインスタンスを作成してRenderViewに渡します。 レンダリングビューは、レンダリングモデルを取得し、Androidビューに適用する役割を果たします。

注:再利用可能なButtonRenderViewをリファクタリング(内部構造を変える)できます。

class StopwatchRenderView(root: ViewGroup): RenderView<StopwatchRenderModel> {
    private val timePassed: TextView = root.findViewById(R.id.time_passed_text_view)
    private val startStopButton: Button = root.findViewById(R.id.start_stop_button)
    private val resetButton: Button = root.findViewById(R.id.reset_button)

    override val renderer: Renderer<StopwatchRenderModel> = Renderer.create { model ->
        timePassed.text = model.timePassed

        startStopButton.text = model.startStopButton.text
        startStopButton.setOnClickListener {
            model.startStopButton.onSelected()
        }

        resetButton.text = model.resetButton.text
        resetButton.setOnClickListener {
            model.resetButton.onSelected()
        }
    }
}

レンダリングロジックが邪魔にならないように、実際にレンダリングモデルを作成する方法を見てみましょう。 レンダリングモデルの作成は、Formulaインターフェイスの責任です。 それを拡張するStopwatchFormulaクラスを作成しましょう。

class StopwatchFormula : Formula<Unit, StopwatchFormula.State, StopwatchRenderModel> {
    // We will use this a little later.   
    object State

    override fun initialState(input: Unit): State = State

    override fun evaluate(
        input: Unit,
        state: State,
        context: FormulaContext<State>
    ): Evaluation<StopwatchRenderModel> {
        return Evaluation(
            renderModel = StopwatchRenderModel(
                timePassed = "5s 10",
                startStopButton = ButtonRenderModel(
                    text = "Start",
                    onSelected = { /* TODO */ }
                ),
                resetButton = ButtonRenderModel(
                    text = "Reset",
                    onSelected = { /* TODO */ }
                )
            )
        )
    }
}

Formulaインターフェイスは、入力、状態、およびレンダリングモデルの3つの汎用パラメーターを取ります(この例では入力を使用しません)。 Formulaでは、Stateと呼ばれる単一のKotlinデータクラス内にすべての動的プロパティを保持します。 とりあえず、空のStateオブジェクトをプレースホルダーとして使用します。 evaluate関数は現在の状態を取得し、レンダリングモデルの作成を担当します。

現在の実装では、常に同じレンダリングモデルが返されます。 動的にする前に、この実装をレンダリングビューに接続しましょう。 Formulaインターフェースから観察可能なRxJavaを作成するstateと呼ばれ拡張機能があります。

class StopwatchActivity : FragmentActivity() {

    private val disposables = CompositeDisposable()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.stopwatch_activity)

        val renderView = StopwatchRenderView(findViewById(R.id.activity_content))
        val renderModels: Observable<StopwatchRenderModel> = 
            StopwatchFormula().start(Unit)
        disposables.add(renderModels.subscribe(renderView.renderer::render))
    }

    override fun onDestroy() {
        disposables.clear()
        super.onDestroy()
    }
}

簡単にするために、このロジックをアクティビティに直接配置しました。 実際のアプリケーションでは、AndroidX ViewModelやformula-androidモジュールなどの構成の変更に耐えるサーフェス内に配置する必要があります。

レンダーモデルの変更を観察するので、UIを動的にしましょう。 たとえば、ユーザーが[開始]ボタンをクリックすると、この変更を反映し、ボタンを更新して[停止]を表示します。 ストップウォッチが開始された場合に追跡するための動的プロパティが必要です。 状態を更新してisRunningを含めましょう。 最初にisRunningをfalseに設定します。 ReduxまたはMVIを使用したことがある場合、これはおなじみのはずです。

class StopwatchActivity : FragmentActivity() {

    private val disposables = CompositeDisposable()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.stopwatch_activity)

        val renderView = StopwatchRenderView(findViewById(R.id.activity_content))
        val renderModels: Observable<StopwatchRenderModel> = 
            StopwatchFormula().start(Unit)
        disposables.add(renderModels.subscribe(renderView.renderer::render))
    }

    override fun onDestroy() {
        disposables.clear()
        super.onDestroy()
    }
}

簡単にするために、このロジックをアクティビティに直接配置しました。 実際のアプリケーションでは、AndroidX ViewModelやformula-androidモジュールなどの構成の変更に耐えるサーフェス内に配置する必要があります。

レンダーモデルの変更を観察するので、UIを動的にしましょう。 たとえば、ユーザーが[開始]ボタンをクリックすると、この変更を反映し、ボタンを更新して[停止]を表示します。 ストップウォッチが開始された場合に追跡するための動的プロパティが必要です。 状態を更新してisRunningを含めましょう。 最初にisRunningをfalseに設定します。 ReduxまたはMVIを使用したことがある場合、これはおなじみのはずです。

class StopwatchFormula : Formula<Unit, StopwatchFormula.State, StopwatchRenderModel> {

    data class State(
        val isRunning: Boolean
    )

    override fun initialState(input: Unit): State = State(
        isRunning = false
    )
    
    ....
}

開始/停止ボタンレンダリングモデルの作成を更新しましょう。

fun startStopButton(state: State): ButtonRenderModel {
  return ButtonRenderModel(
    text = if (state.isRunning) "Stop" else "Start",
    onSelected = { /* TODO: toggle the stopwatch */ }
  )
}

毎回ユーザーがこのボタンをクリックするたびに、isRunningを切り替えて新しいレンダリングモデルを作りたいです。 FormulaContextを使用してこれを実現します。

fun startStopButton(state: State, context: FormulaContext<State>): ButtonRenderModel {
    return if (state.isRunning) {
        ButtonRenderModel(
            text = "Stop",
            onSelected = context.callback {
                transition(state.copy(isRunning = false))    
            }
        )
    } else {
        ButtonRenderModel(
            text = "Start",
            onSelected = context.callback {
                transition(state.copy(isRunning = true))
            }
        )
    }
}

FormulaContextは、ランタイムによって渡される特別なオブジェクトであり、これコールバックを作成して新しい状態への遷移を定義できます。 毎回onSelectedが呼び出されるたびに、isRunningが反転する状態に遷移します(trueの場合はfalseになり、逆の場合も同じです)。 状態を変更する代わりに、データクラスのcopyメソッドを使用して新しいインスタンスを作成します。 遷移により、evaluateが新しい状態で再度呼び出され、新しいレンダリングモデルが作成され、Observableが新しい値を発行(emit)し、UIが更新されます。

機能するボタンができたので、実際にはストップウォッチを実行し、時間が経過するにつれてUIを更新する必要があります。 stateクラスに新しいプロパティを追加しましょう。

class StopwatchFormula : Formula<Unit, StopwatchFormula.State, StopwatchRenderModel> {

    data class State(
        val timePassedInMillis: Long,
        val isRunning: Boolean
    )

    override fun initialState(input: Unit): State = State(
        timePassedInMillis = 0,
        isRunning = false
    )
    
    ....
}

ミリ秒から表示値を作成する実際の実装は少し複雑なので、経過時間にミリ秒を追加してみましょう。

fun formatTimePassed(timePassedInMillis: Long): String {
  // TODO: actually format it
  return "${timePassedInMillis}ms" 
}

timePassedInMillisを更新するには、RxJava interval operatorを使用します。

Observable
  .interval(1, TimeUnit.MILLISECONDS)
  .observeOn(AndroidSchedulers.mainThread())

ストップウォッチの実行中に、このオブザーバブルをリッスンし、各イベントでtimePassedInMillisを更新します。 この動作を追加するには、evaluate関数機能を更新する必要があります。 各状態の変更の一部として、Formulaは実行およびリッスンするサービスを決定できます。 evaluateを更新し、context.updatesブロック内で条件付きロジックを利用して、オブザーバブルを実行するタイミングを宣言します。

Observableを手動でサブスクライブしないことに注意してください。 Formulaランタイムがサブスクリプションを処理(対応)します。

override fun evaluate(
    input: Unit,
    state: State,
    context: FormulaContext<State>
): Evaluation<StopwatchRenderModel> {
    return Evaluation(
        renderModel = ...,
        updates = context.updates {
            if (state.isRunning) {
                val incrementTimePassedEvents = RxStream.fromObservable {
                    Observable
                        .interval(1, TimeUnit.MILLISECONDS)
                        .observeOn(AndroidSchedulers.mainThread())
                }

                events(incrementTimePassedEvents) {
                    transition(state.copy(
                        timePassedInMillis = state.timePassedInMillis + 1
                    ))
                }
            }
        }
    )
}

ストップウォッチを開始すると、時間が経過したラベルが更新されるようになりました。 ここのロジックは非常に単純です。isRunningがtrueの場合、incrementTimePassedEventsをリッスンし、timePassedInMillisを増加します。 更新メカニズムはRxJavaに依存(agnostic to)しないため、RxStreamを使用してラップする必要があります。 イベントコールバックはUIイベントコールバックと非常に似ていますが、既にスコープされているため、context.callbackを使用する必要はありません。

リセットボタンのクリックを処理(対応)する必要があります。 これは、開始/停止ボタンの実装と同じです。 ここで新しい概念はありません。毎回リセットボタンが選択されるたびに、timePassedInMillisが0に設定され、isRunningがfalseに設定される新しい状態に移行します。

fun resetButton(state: State, context: FormulaContext<State>): ButtonRenderModel {
    return ButtonRenderModel(
        text = "Reset",
        onSelected = context.callback {
            transition(state.copy(timePassedInMillis = 0, isRunning = false))
        }
    )
}

これで完了です。 ここでソースコードを見つけることができます。

もっと学びましょう

composition、testing、side-effectsなど、まだ検討していないことがたくさんあります。 詳しくを知りたい場合は、ドキュメントサンプル、またはGithubリポジトリをご覧ください。 質問やフィードバックがある場合は、Twitterで連絡してください。

Formulaのようなプロジェクトに興味がありますか? Instacartの現在の募集をご覧ください。

原文タイトル:What the Formula? Managing state on Android

原文作者:Laimonas Turauskas

原文リンク先:https://tech.instacart.com/what-the-formula-managing-state-on-android-f5569ce09274

今すぐシェアしよう!
今すぐシェアしよう!