Jetpack Compose+Coroutineを試してみた
AndroidStudio4.0Previewで 以前みたいな面倒なこと なくJetpackCompose
が試せるようになってみたので、早速定番の OpenWeatherAPI でサンプル作ってみました。
といっておきながら、ktor-client を組み込むとなぜかビルド時に Background Code Generetaion Error がでてうまく組み込めず。OpenWeatherAPIといっておきながらネットワークアクセスは一旦挫折しました。
このエラーですが、kotshi/moshi や koin を使っても発生しました。kapt系だと全般的に発生するのではないかと思います。
以下コツなどなど。レイアウト自体はJetNewsを読んだほうがいいと思うので省略します
Hooks
前回の記事でも軽く書きましたが、 ComposeではReactHooks的な状態管理ができます。 あまり公式は推してませんが、これが一番おもしろいと思っています(笑)
軽く説明すると
+state
: 状態を管理するモノを作る。値変更時に自動で通知が飛ぶ+model
: modelといいつつ、ReactでいうEffect。該当関数で一回しか走らないような処理を書く
例で示しましょう。ネットワークからニュース一覧を取得するようなコードを考えます。
fun NewsList() { val newsList: List<News> = +state { //初期値空。この初期化は一度のみ呼び出される emptyList() } +model { //coroutineしてると思ってください //ここは一回しか呼び出されない val list = networkAccess().await() newsList.value = list } //このへんは関数呼び出し(=再レンダリング)のたびに何度でも走る newsList.value.map { NewsRow(it) } }
ざっとこんなイメージです。+state
で value を持ったクラスが生成され、変更すると自動でこの NewsList
の再レンダリングが走ります。
また一回しか走らないようでは困る、というときのために +modelFor
があります。こっちは引数が変わるともう一度実行されます。
fun WeatherList(cityId: Int) { //色々省略 +modelFor(cityId) { //cityIdに応じたデータ取得 //cityIdが変化すると実行される } }
@Model
もう一個サンプルをみて面白かったのが、@Modelアノテーション
です。これをつけると値の変更時に更新通知が飛び、再レンダリングが走るようです
公式サンプルでは画面遷移に利用してました。
@Model object AppState { var scene: Scene = Scene.Home() } sealed class Scene { object Home = Scene() data class Search(val keyword: String) = Scene() } //--------------- fun AppMain() { Button { onClick = { //画面遷移 //(Sceneをみてレンダリングする部分は省略) AppState.scene = Scene.Search("hoge") } } }
こんなところ。
ただしこれGlobal(Singleton)なもの以外では使えるのかどうかちょっと疑問。というのも、普通の関数内に書くと 再レンダリングのたびにクラスが再生成されちゃう はずなんですよねぇ...
+model でGlobalなインスタンスとして管理、もできなくはないですが関数内で閉じないのは気持ち悪いです。なにかやり方あるとは思いますが、まだ見つかってません。
Redux的な状態管理ならこれだけでいけそうですね。
といいつつ、これって実質StateなのでModelというのは紛らわしい とも思ってます。 Modelといいつつ、+model
と別物ですし。(+modelでEffectクラスが返ってくるのもアレですが)
いずれリネームされるんじゃないかな?と思っています。
状態遷移の分離
でこの +state
、おもったより賢く、以下のようなコードが意図通りに動作します。
//カウントアップ用のView fun Timer() { val time = useTimer() Text(time) } //カウントアップ部分 //戻り値に注目 fun useTimer(): Int { val state = +state { 0 } +model { //coroutineと思ってください while true { delay(1000) state.value += 1 } } return state.value }
useTimer()内では単にIntを返しているだけで一見すると更新通知されないように見えますが、ちゃんと更新通知が動きました。
これは便利、というよりこれがあるとデータ取得のみを関数内で完全に閉じ込めることができます。
より現実的な例は以下でしょうか?(一部略。ビルド通りません)
fun WeatherList(cityId: Int) { val weathers = useWeatherList(cityId) //レンダリングは省略 } fun useWeatherList(cityId: Int): List<Weather> { val state = +state { emptyList() } +modelFor(cityId) { //ネットワークアクセス val client = HttpClient() val result = client.get<List<Weather>>(url + cityId).await() state.value = result } return state.value }
こんなところ。こうすれば useWeatherList
はいろいろな場所から簡単に使い回せます。ロジックの共通化が楽でいいですね!
Coroutine
で、Coroutine。あってるのかどうかちょっと自信がないですが、以下のようなコードがたぶん正解ではないかと思います。
fun CoroutineSample() { val state = +state { 0 } //CoroutineContextの取得 val ctx = +ambient(CoroutineAmbient) val scppe = CoroutineScope(ctx) +model { //Coroutine発動 scope.launch { delay(1000) val result = asyncTask().await() state.value = result } } //以下のようなGlobalScopeでも動くがキャンセルが難しい... //上のパターンが正解だと思うが自信はあまりない GlobalScope.launch { } }
この +ambient
がミソ。たぶん現在のContext(Compose的な意味。Activityなどではない)にあったグローバルななにかといいますか、そういうものを取得するものだと思います。
たとえば、Android-Contextがほしい場合は次のようにします
val context = +ambient(ContextAmbient)
context.resource.getString(R.string.text)
このContextAmbientがまだナゾ。よくわかってません。おまじないです。
うまくやれば自作クラスもこれに応用できそうではあります。DI的に使える、といいますか。がまだやり方わかっていません。そんな気がする、という程度です。
他
テーマ取得系がなかなかおもしろいです。+themeXXX
を使います。
//primary色の取得。primaryはCompose側で定義されている定数 val color = +themeColor { primary }
+theme
で現在のContextに沿った色取得、ということではないかと思います。
これを応用すると、「テーマから一部だけ値を差し替える」ことができます。
//フォントやサイズやデフォルトのBodyテーマを使い //色のみ差し替える val text = (+themeTextStyle { body }).copy( color = Color.Black )
このように値を引っ張ってきて copy
で強引に上書きできます(厳密には新規インスタンス生成ですけどね)。
注意点として、 +themeを () で囲ってください。 これを怠ると演算子の優先順位の都合でビルドエラーです。
まとめ
とまぁなかなか楽しかったです。個人的にはHooks的な仕組みがあるぶん、SwiftUIよりすきですね(笑)
が、まだまだDEVなので不安定です。とくにプレビューはプロジェクト生成直後以外、まともに動作しませんでした。コンポーネントの数もドキュメントも足りてなく、実戦投入は早くて1年半後ではないでしょうか?
以下個人的雑感。あまり読まなくていい感想です(笑)
Flutterとの棲み分けは?
一番きになるのがコレ。
- ほぼ似たような仕組み
- クロスプラットフォーム
- すでに商用利用可能なぐらい安定している
- リロードが早い
- 再ビルド必要なCompose-Previewの比でない
- 習熟コストの低いDart言語
- Kotlinのほうが便利ではありますが
というFlutterがあるのに、なぜGoogleはJetpackComposeを作るのだろう?全く理解できていません。Flutterから開発者引き抜いた、とかどこかの動画で見た記憶もあり本当にナゾ。
今の所、劣化自社競合以外のなにものでもないです。Frameworkが安定してもクロスプラットフォーム化する気がない以上、Flutterには勝てないでしょう。Flutterの場合、言語/RuntimeそのものをGoogleでコントロールできるもの大きいです。(Javaに縛られるKotlin/AndroidRuntimeよりは楽でしょう)
どこまでやる気なんですかねぇ?イマイチ読めないです。
正直、 JetpackComposeを学習するよりはFlutterをやったほうがいい と思います。
Declarative?
も一個自分が気に入らないといいますか(笑)、疑問に感じてるのがココ。SwiftUIやComposeでよく言われる、DeclarativeView
。状態から一意にViewを生成できる、というやつですね。
が、Declarativeである != 関数でViewを組む ではないでしょうか?
view = f(state)
この式さえ成り立つ仕組みができればそれは Declarative だと思っています。(言葉の定義の問題な都合、宗教論争的な話でもあるので細かいツッコミはおいといて...)
実はAndroidはすでにほぼDeclarativeなしくみがあります。えぇそうです、 DataBinding+ViewModel
です。
普通にこの2つを使えば、 ViewModelの値が同じなら、出力されるViewも同じ はずです。つまり Declarative.
Viewのインスタンスに直接アクセスして変更、なんてことをしなければ大丈夫なはずです。(Reactでも直にDOMアクセスすれば当然vie=f(state)崩れるので、直アスセスは「そんなことしない」で済ませていいと思ってます。最もAndroidのほうが容易にできてしまいますが)。
すでに数年前にAndroidはDeclarativeな環境ができてしまっているんですよね。DataBindingの仕組み+状態を一つの構造体にまとめる+その構造体からViewの可変部分を変更する ことができればどんなプラットフォームでもDeclarativeになると思っています。
ではComposeとDataBindingを比較すると...?
- Compose
- 常にツリー生成が必要
- ツリーの差分比較>パッチを当ててViewを更新
- DataBinding
- ダイレクトにViewインスタンスを更新
どちらがパフォーマンス出るか、といわれるとBindingではないでしょうか?Composeの描画パフォーマンスのほうがAndroidNativeAppを上回っており逆転する可能性はありますが、基本的には差分比較がない分だけDataBindingが有利だと思っています。
とくにDataBindingだと「更新範囲を狭める努力」が不要なんですよね。自動生成コードがいいようにしてくれます。また、再レンダリングにまきこまれて状態吹っ飛んだ、とかも普通発生しないでしょう。この手のVirtualDOM的構造で気にしなきゃいけないことが減ります。
なので、個人的にですがDataBindingさえあればComposeは不要では?と思ったりしています。ComposeでConstraintLayoutやMotionLayoutはまず無理だと思いますし、この点でもLayoutXMLは馬鹿にできない能力を持っています。
今の所Composeは「簡単なViewをサクッとつくる」分にはいいです。特にコンポーネントの再利用性は高いですね。include
があるといっても、Composeほどガシガシ切り分ける気にはなりません。
テーマもわざわざStylesに書き出さず、コードで管理できる楽さはあります。
とはいえこれらのメリットは全部Flutterも持ってい(略)
iOS-Storyboardは再利用性が劣悪なので、SwiftUIがもてはやされる理由はまだわかります。
標準のUIKitでDeclarativeな仕組みが一切ないので、なにかのFramework使うにしても面倒ですし。
まとめ2
ここまで書いてなんですが、Google推しというだけで今後流行り、いずれDataBindingを駆逐していくとは思います。自分の好みはさておき、エンジニアとしてついていく必要はあるのでがんばらねば。
が、いまいちFlutterとの棲み分けがナゾ。ここだけはハッキリさせてほしいですね。
もちょい環境が安定してきたらリトライしてみます。