💡

Compose Firstライブラリ群 Soil について

2024/05/22に公開1

最近推しライブラリができたので紹介します。

Soilとは?

SoilというComposeプロジェクトで使用できるライブラリ群です。KMM対応もされています。まだ生まれて間もないライブラリ群なので執筆時点では 1.0.0-alpha01 なのでこの記事を読むタイミングによっては大きな変更が入っている可能性もありますのでご注意を。

https://github.com/soil-kt/soil

https://docs.soil-kt.com/

Soilの3ライブラリ

Soilはライブラリ であり (実際 SoilのREADMEにも "Compose-First Power Packs" という記述があります。) 、 以下3つのライブラリの総称となっています。

Soilのライブラリ キーワード 影響を受けたReactライブラリ
Soil Query データフェッチとミューテーション TanStack Query, SWR, RTK Query
Soil Form フォームの状態管理 React Hook Form
Soil Space 状態の保持 Recoil, Jotai

表にもある通り、3つともReact系にReactユーザにとってはお馴染みのライブラリに影響を受けています。

Soil Query はアプリのデータ取得とミューテーションを管理することができます。今までであればModel層(Repository)に自力で書いていたキャッシュの管理や実装が複雑な無限ローディングなどの処理を簡潔に書くことができるようになっています。

Soil Form はフォームを効率よく実装するためのライブラリです。今までViewModelがになっていたフォームの入力値管理について、より直感的に状態を管理したり、バリデーションやをスッキリ書くことができるようになるでしょう。

Soil Space は状態管理ライブラリです。こちらも今まではViewModelなどで管理されていたどのデータをどのように保持するか、どのように取得するかを決めるライブラリです。

Soilはある機能を実装するためというよりはある機能を実装する際にあるあるな部分を共通化したライブラリ群と言えそうです。
例えばSoil Queryは KtorやRetrofit, Roomといったデータ層と呼ばれていたライブラリと、Soil FormはViewModelなどで管理していた入力するUIのロジック部分を、Soil SpaceはViewModelなどで管理していた状態の保持を共通化、抽象化してくれるライブラリになります。あるあるな部分を共通化してくれているという意味でSoil (土壌)なのかもしれませんね。

SoilはMVVMと仲良くできないかもしれない

SoilはMVVM構成のアプリにそう手軽に導入することはできないような設計と言えそうです。(無理にロジックがいろんなところに散らばってカオスになること間違いなし)

次からSoilの3ライブラリを詳しくみていきますが、MVVMに慣れてしまったAndroidエンジニアの方はあまり受け入れ難いAPIが次々と出てくるかもしれません。

Soil Query

Soil Query はアプリの取得とミューテーションを管理することができます。TanStack QueryやReact Suspenseにインスパイアされていると言えそうです。

本記事では以降、公式ドキュメントをもとにSoil Queryの基本的な使い方をまとめてみました。
Soil Query はアプリの取得とミューテーションを管理することができると言われてピンと来ていない方も、一旦使い方から入ってみるとその良さが実感できるかもしれません。

Soil Queryの使用方法
private val swrClient = SwrCache(SwrCacheScope())

class HelloQueryKey : QueryKey<String> by buildQueryKey(
    id = QueryId("demo/hello-query"),
    fetch = { // suspend block
        delay(2000)
        "Hello, Query!"
    }
)

// @Composable 関数内
SwrClientProvider(client = swrClient) {
  val key = remember { HelloQueryKey() }
  when (val query = rememberQuery(key)) {
    ErrorBoundary(fallback = { Text("Error :(") }) {  // エラー時に表示するもの
      Suspense(fallback = { Text("Loading...") }) {  // ローディング中に表示するもの
        Await(query) { result ->
          // データフェッチ時に表示するもの
          Text(result)
        }
        Catch(query) { e ->
          // エラー時に表示するもの, エラーが処理しきれない場合はThrowで親コンポーザブルに投げる
          // Custom error handling
          // if (e is MyCustomError) {
          //    ...
          // }
          Throw(e)
        }
      }
    }
  }
}

一つづつ見てみます。

1. SwrCacheの作成
private val swrClient = SwrCache(SwrCacheScope())

まずは SwrCache() を作成します。これはKtorやRetrofitといったAPI通信ライブラリにおけるClientの役割を持っており、この内部にキャッシュ等が保持されます。画面ごとに複数SwrCacheを作成することもできますが、キャッシュが保持されている場所であるため基本的には1つで良さそうとドキュメントに記載があるため単一のSwrCacheを使い回すほうが良さそうです。

作成したSwrCacheは SwrClientProvider Composableを使って流し込み全体のセットアップは終了です。SwrClientProviderは内部では単に CompositionLocalを使って下のComposableに流して混んでいるようです。

2. クエリごとにQueryKeyを定義
class GetPokemonListQuery : QueryKey<List<Pokemon>> by buildQueryKey(
    id = QueryId("pokemonList"),
    fetch = { // suspend block
        delay(2000)
        "Hello, Query!"
    }
)

次に1つのクエリごとに buildQueryKey を使ってクエリのキーと取得方法を定義します。指定したidがキャッシュのキーになりそうですね。

またここは取得であるQuery以外にも、無限ローディング(ちょっとずつ読み込むやつ)をサポートするためのInfiniteQueryKey、更新を目的としたMutationが用意されているようです。

3. データを取得する
val key = remember { HelloQueryKey() }
val query = rememberQuery(key)

先ほど定義したQueryKeyや rememberQuery() を使ってデータを実際に取得します。この rememberQuery がただQueryKeyのfetchを呼び出すだけでなく 1. SwrCacheの作成 で流し込んだSwrClientProviderを使ってキャッシュの管理等も面倒を見てくれます。

取得した query は QueryObject という型になっており、これを使って次のステップで型安全に

4. QueryObjectで表示を出し分ける
ErrorBoundary(fallback = { Text("Error :(") }) {  // エラー時に表示するもの
  Suspense(fallback = { Text("Loading...") }) {  // ローディング中に表示するもの
    val key = remember { HelloQueryKey() }
    val query = rememberQuery(key)
    Await(query) { result ->
      // データフェッチ時に表示するもの
      Text(result)
    }
    Catch(query) { e ->
      // エラー時に表示するもの, エラーが処理しきれない場合はThrowで親コンポーザブルに投げる
      // Custom error handling
      // if (e is MyCustomError) {
      //    ...
      // }
      Throw(e)
    }
  }
}

最後にErrorBoundaryとSuspenseを使って画面の出しわけを行っています。
ErrorBoundaryは内部でthrow(Throw)された例外を処理します。Suspenseはローディングかどうかで表示するコンポーネントを出し分けています。エラー時, ローディング中に fallback の @Composableラムダに渡した内容が表示されます。

上記の例ではSuspenseのラムダ内では、 Await()Catch() Composableを使用しています。Awaitは第一引数で指定したQueryObjectがSuccessになった場合にラムダ内を表示します。逆にCatchはQueryObjectがErrorになった場合にラムダ内を表示します。

React18ではSuspenseはレンダリングの中断時に処理を出し分けるためのコンポーネントでしたが、React18以降とは違い Composeにはまだレンダリングの中断機能はないため、実際にはただ宣言的に呼べるようにしているだけが実態になっています。

個人的にはレンダリングの中断機能がない今のComposeにとって、Suspenseのは「宣言的になった」ぐらいしか変化はなく、宣言的にLoading画面を出し分けるメリットが理解できていないことも相まって個人的にはwhenを使った出しわけでもいいのではないかと考えていたりします。

実際各状態のハンドリングに関してはwhen式を使って以下のような分岐もできます。
こっちの方が見やすくね...?

when式を使った分岐
val key = remember { HelloQueryKey() }
when (val query = rememberQuery(key)) {
  is QuerySuccessObject -> Text(query.data)
  is QueryLoadingObject -> Text("Loading...")
  is QueryLoadingErrorObject,
  is QueryRefreshErrorObject -> Text("Error :(")
}

詳細は公式ドキュメントをご覧ください。

Soil Form

Soil Form はフォームを効率よく実装するためのライブラリです。React Hook Formにインスパイアされています。

Soil Formでは FormとControllerというComposableとFieldControlという状態(State)が登場します。Formは一つの入力のまとまり(例:ログインフォームやお問い合わせフォームなど)、Controllerは一つ一つの入力欄(例:ログインフォームにおける名前を入力するTextField)と考えると良さそうです。

UDFに従って、ComposableであるFormとControllerはUIの表示とイベントのハンドリングができるようになっています。

FormとController
Form(
  onSubmit = { /* サーバにデータを送信するなどのフォーム送信時の処理 */ },
  initialValue = MyFormData(),
  policy = FormPolicy.Minimal
) { // this: FormScope<MyFormData>
  // controlについては後述
  Controller(control) { field -> // Field<String>
    TextField(
      value = field.value,
      onValueChange = field.onChange,
      isError = field.hasError,
    )
  }
}

フォームのそれぞれのフィールドの状態を表すFieldControlは rememberFieldRuleControl() を使ってアクセスできます。このFieldControlを先ほど紹介したControllerにわたし、Controllerが実際に表示を担う

FieldControl
val usernameControl = rememberFieldRuleControl(
  name = "username",
  select = { this.username }, // T.() -> V
  update = { it },   // T.(V) -> T
) { // this: ValidationRuleBuilder<String>
  // バリデーションを記述する
  notBlank { "ユーザ名くらい入力してよ" }
}

Soilはデフォルトでフォームにあって欲しい最低限の状態管理を実装してくれています。
例えば「1つでもエラーのフィールドがあったら送信ボタンを押せない状態にする」「フォーカスが外れた時にバリデーションを実行してバリデーションを実行してUIを変える」といった状態の管理などです。

こちらも詳しくは公式ドキュメントを参照してください。

https://docs.soil-kt.com/guide/form/hello-form.html

Soil Space

Soil Space は状態管理ライブラリです。 RecoilやJotaiにインスパイアされています。

従来のMVVMアーキテクチャでは画面単位のViewModelまたはデータ層に状態を持たせて保持していました。

これに対し、Soil Spaceでは Atom という単位で状態を扱うアプローチをとっています。AtomはComposableの外でGlobalに定義します。

val counter1Atom = atom(0, saverKey = "counter1")
val counter2Atom = atom(0, saverKey = "counter2")

Atomには追加で AtomScope を渡すことでそのAtomが有効な期間(Atomの寿命)を決めることができます。アプリケーションに紐づくAtomなのか画面に紐づくAtomのか...それぞれ定義することができます。なんだかHiltのScopeやComponentを考えている時みたいですね。

val navGraphScope = atomScope()
val screenScope = atomScope()

val counter1Atom = atom(0, saverKey = "counter1", screenScope)
val counter2Atom = atom(0, saverKey = "counter2", navGraphScope)

なおCompose Multiplatformのナビゲーションライブラリであるvoyagerに対するScopeが簡単に作成できるようになっているようです。Compose Navigationのサポートも早めに出て欲しいナ...

voyager
class HelloSpaceScreen : Screen {
  @Composable
  override fun Content() {
    val navigator = LocalNavigator.currentOrThrow
    AtomRoot(
      currentScreen to rememberScreenStore(),
      navScreen to navigator.rememberNavigatorScreenStore(),
      fallbackScope = { currentScreen }
    ) { ... }
  }
}

話を戻して、Composable外で定義した Atom をComposeの世界で使うためにはAtomState に変換する必要があります。そのためには rememberAtomState() を使います。戻り値がMutableStateになっており、通常の remember { mutableStateOf(初期値) } の形式で定義したStateと同じノリで使うことができます。便利ィ!!

var counter1 by rememberAtomState(counter1Atom)

Button(onClick = { counter1++ }) {
  // ...
}

またプロジェクトが複雑になるにつれ、複雑なAtomも必要になってくるでしょう。例えば「他のAtomを参照して計算されるAtom」があります。その状況では AtomSelector を使用すると良いでしょう。以下のようにシンプルに状態を定義することができます。

AtomSelectorの定義
val sumAtom = atom {
    get(counter1Atom) + get(counter2Atom)  // counter1Atomとcounter2Atomを足した値を保持する
}

これらを使うことで従来ではViewModelやHiltを使って実現していた状態保持の仕組みをよりシンプルに構築できるかもしれません。

Soilを推す理由

私がSoilを推す理由は単純に「Reactに似たComposeならアーキテクチャやエコシステムもReactに寄せるべき」と考えるからです。

元々Reactに大きな影響を受けていたComposeですが、既存のAndroidアプリとの協調性の観点からMVVMアーキテクチャを維持しつつ導入できるようにMVVMでいうView層に影響をとどめて開発されています。ですがReactらしさ、Composeの良さを引き出すためにはMVVMアーキテクチャではよくないのではとしばしば感じることがあります。
ここでSoilの出番です。SoilではMVVMとは根本から異なるアーキテクチャに従ってReactライクにアプリを構成することができます。

Reactユーザから高い支持を受けている TanStack Query, React Hook Form, Jotai からインスパイアされているという点では、Androidエンジニアにこそ受け入れがたくとも、そこまでゲテモノな思想というわけでもないと言えるのではと考えています。

Soilのロードマップ

ドキュメントのWhat's Next? にはそれぞれ以下のように書かれています。

  • Query ... データのフェッチとキャッシュをシームレスに行う。より宣言的に記述され、コードがより読みやすくなります。
  • Form - 拡張可能な検証コントロールとフォーム状態管理。recompositionの影響を最小限に抑えます。
  • Space - 柔軟なスコープ付き状態管理。ナビゲーションライブラリと連携して新しいスコープを作成します

今Soilを実務で使うか

結論、断じてNo です。リリースして間もなかったり 周辺ライブラリとの統合が若干不十分であるため、実務で導入し使うにはまだ早すぎます。

ただ個人的にはMVVMじゃないandroidアプリアーキテクチャの土台になる思想として100点のライブラリと言いたいので、この思想が広まって実務で使えるレベルまで成熟するのを楽しみにしています。

Discussion