React のエンジニアが Jetpack Compose を勉強してみた

育休前は React と go をよく書いていましたが、育休が終わって Android の仕事につくことになったので、Jetpack Compose の勉強をしました。といっても自分ばズブの素人というわけではなく、Jetpack Compose が導入される前には Android のエンジニアで、確かマルチモジュールが世の中に出始めたかな〜ぐらいの時代で Web 方面に進んだので、そこらへんの時代で知識が止まっています。

Jetpack Compose とは

https://developer.android.com/jetpack/compose
Android ネイティブアプリケーションを、宣言的 UI でアプリを作ることができるキットです。これまで Android は Activity(いわばウィンドウ。一画面に必ず1つしかない) や Fragment(いわばウィンドウの上における、View の土台みたいなもの。1画面、つまり Activity 上に複数置くことができる) という上に、xml でレイアウトを指定(HTMLみたいな感じ)して、描画された View に対し、Activity や Fragment にコードを書いて View をバインドしたり、クリックイベントを記述したりしていました。

Jetpack Compose では、土台は Activity や Fragment ではあるものの、この2つはもっぱら Jetpack Compose のルート要素置き場として機能します。xml でレイアウトを書く手法は廃止され、表示用コードとロジックが合体しているような感覚です。ものすごく雑にいうと React みたいなやつです。

React の知識と照らし合わせて勉強

https://developer.android.com/courses/android-basics-compose/course
https://developer.android.com/courses/pathways/compose
ドキュメントやコードラボをやっていきました。コードラボでコードを書きながら、「これは React でいうこれなんだな」という突き合わせをしながら理解していきました。その上で Compose ならではの理解を深めていくと良いのかなと思っています。

React で言うコンポーネントは Composable な関数で表す

React で以下のように書くと、ファンネル勤怠 と書かれたテキストが表示されます。

 function Title() {
   return <div>ファンネル勤怠</div>;
 }

これを Android でやろうとするとこうなるわけです。

 @Composable
 fun Title() {
  Text(text = "ファンネル勤怠") 
 }

なんとなく、Web エンジニアでも親しみやすい感じになっているのではないでしょうか。やはり宣言的 UI というだけあって React によく似ています。React と違って Class でコンポーネントは作れず、全て関数で UI を作ります。またコンポーネントとは言わずコンポーザブル(composable)と言います。UIは、状態が変化したらコンポーズ(compose)され、その際にコンポーザブルな関数がひとしきり呼ばれ再描画される仕組みです。

もう一点、大きな違いがあります。それは描画する要素を戻り値として返す必要があるか、ないかという部分です。

今一度 React のコードを見てみると

 function Title() {
   return <div>ファンネル勤怠</div>;
 }

ReactElement を返しているのがわかります。つまり、UI は関数の最後でまとめて return する必要があります。

ところが Jetpack Compose の関数は返り値は Unit です。これは Typescript でいう Void に相当します。すなわち、UI を関数の最後で返す必要はなく、

 @Composable
 fun Title() {
  Text(text = "ファンネル勤怠")
  val mes = "xxxxx"
  Box {
    Text(text = "詳細")
    println(mes)
  }
  println(mes)
 }

こういった記述も可能なわけです。

Compose で最高に楽になったのはリストではないでしょうか。従来は RecyclerView という View クラスを実装することでリストを作っていました。これはAdapter とか ViewHolder とか登場人物が少し多く、細かなコードを書いてリスト中の item をどのように使い回すかを書く必要がありました。そうしないとスクロール時にパフォーマンスが悪化するからです。

Compose だとこのように書けます

    LazyColumn {
        items(items = items, key = { item -> item.id }) {
            WorkingTimesListItem(
                item = it,
                onClickDetail = onClickDetail
            )
        }
    }

key がついているのは React 経験者ならピンとくるものであると思います。ちなみに Kotlin は関数の引数で、一番うしろにあるラムダは、関数の引数ブロックを飛び出して、{ ... } ブロックを使って書くことが出来ます。 LazyColumn も、本来であれば LazyColumn(content: ここに Composable な関数を書く) となるのですが、言語機能によってこう書くことができるため、宣言的 UI としてネストしていったとしても読みやすい、親和性の高いものになっている気がします。

状態

全ては再コンポーズされるので、そのままだとコンポーズ関数内に状態を保持し続けることは出来ません。React では useState を使っていたりしますが、Compose にもにたものは用意されています。

 function XXX() {
 ...
   var isClicked by remember { mutableStateOf(false) }
   ...

こう宣言すれば、isClicked は再コンポーズ後でも失われないので、状態を保持できます。by は Property を委譲する Kotlin の言語機能です。Kotlin では、今回のように宣言した var の場合は 、デフォルトで setter と getter が生えるのですが、この setter と getter を自分で指定できます。

    var name: String = name
        set(value) {
            field  = value.toLowerCase().capitalize()
            updateCount++
        }

ここで by を使うと、setter と getter に使う関数(つまり、setter と getter を実装した関数)を指定できます。これによって、setter、getter のロジックを抽出し、テストしやすくしたり、再利用性を向上させるのですが、Compose はこの機能を利用して、簡単に状態を保持する事のできる remember を提供している、ということになります。そしてこの remember もコンポーザブルです。

ビジネスロジックを兼ね合わせた状態を考えた場合、ViewModel を利用することになります。https://developer.android.com/jetpack/compose/state#viewmodels-source-of-truth

詳細はこちら https://developer.android.com/jetpack/compose/state

副作用

複雑なアプリを作っていくとどうしても副作用を使わざるを得ないケースが出てきます。Compose は副作用もサポートしています。

useEffect と LaunchedEffect

例えばReact を思い返すと、useEffect があると思います。

 useEffect(() => {
   update();
 });

これに対応するものとして、LaunchedEffect があります。

 LaunchedEffect(Unit) {
 	update()
 }

どちらも再生成に伴いに副作用が走るものになっています。つまり、どちらもコンポーネント、Composable のライフサイクルに沿った形で副作用を行っているということになります。両者とも、新しい副作用を実行するときに、前回の副作用はキャンセルされます。

これを 特定の値が変更された場合のみ副作用を走らせようとする と、React では以下のようになります。例えば id が変わった場合にのみ update() を試みる場合。

 const [id, setId] = useState(defaultId);
 
 useEffect(() => {
   update(id)
 }, [id]);

Composable ではどうでしょうか?

 @Composable
 var id by remember { mutableStateOf(defaultId) }
 
 LaunchedEffect(id) {
 	update(id)
 }

LaunchedEffect 関数にわたす1つ目の引数は React の dependencies array と相似しています。両者とも id が変化しなければ、再生成されても再び実行されません。

LaunchedEffect にわたす関数はコルーチンスコープ内で実行されます。つまり API を叩いたりだとかで UI をブロックされることはありませんし、suspend 関数という非同期処理を呼ぶことが出来ます。

ところで、React では useEffect にはクリーンアップ関数をつけることが出来ます。

  
  useEffect(() => {
    subscribe()
    
    return function cleanup() {
    	  unsubscribe()
    }
  });

これは Compose においては DisposableEffect を使って表現できます。クリーンアップ関数をつけると言うよりも、クリーンアップ関数が必要な場合は DisposableEffect を使う、という感じです。

 DisposableEffect(Unit) {
         subscribe()
         onDispose {
             unsubscribe()
         }
     }

React でいう Context

React では、多層な階層で状態を伝達させていくとバケツリレーが大変であることから、context の利用を考えることがあります。https://ja.reactjs.org/docs/context.html

そこまで頻繁に利用するものでもない気がしますが、例えば react-hook-form なんか使うと体験することがあるのではないでしょうか。

似たものは Compose にも用意されていました。CompositionLocal です。
https://developer.android.com/jetpack/compose/compositionlocal
例では、デザインに使う色をトップの階層で決めたとしても、その色を遥か下の階層で使う場合はめっちゃバケツリレーが必要で不便です、バケツリレーしたくありません、というものです。

まだ自分では使ったことがありませんが、 自分はアプリのデザインをマテリアルデザインで統一する MaterialTheme object は触っており、色やら文字のサイズやらを様々な階層の Composable で触ることになるのですが、これにも CompositionLocal の実装が使われているみたいです。

ナビゲーション

自分が過去に関わったプロダクトでは、フロントエンドのナビゲーションには React を Next.js 上で動かし、そこで Router を使っています。Next.js では、というか Web には URL がありますので、各々の場所でURL を指定して遷移すると思います。 Composable ではナビゲーションを司る Composalbe NavHost が用意されているので、通常はそちらを利用します。

まず、遷移する Composable を全部 NavHost Composable で囲みます。ここで遷移する画面を登録するイメージです。

https://developer.android.com/jetpack/compose/navigation#create-navhost

 NavHost(navController = navController, startDestination = "profile") {
     composable(route = "profile") { Profile(navController = navController) }
     composable(route = "friendslist") { FriendsList(navController = navController) }
     /*...*/
 }

NavHost を親とし、Profile Composable や FriendList Composable を遷移名(route)と紐付けて置いておきます。遷移するときは、この遷移名を使い、NavController を使って遷移します。navController は NavHost から受け取ります。

 @Composable
 fun Profile(navController: NavController) {
     /*...*/
     Button(onClick = { navController.navigate("friendslist") }) {
         Text(text = "Navigate next")
     }
     /*...*/
 }

route で指定している文字列は、定数化したりして記述ミスを防ぐのが一般的なようです。

デザイン

React では、各 DOM に対して style props を指定するのが最も原始的な方法になります。

 <p style={backgroundColor:"red"}>Color</p>

同じアプローチは Composable にも用意されています。Modifier です。

 Text(
   text = "Color",
   modifier = Modifier.background(color = Color.Red)
 )

ただし、React で直接 style props を渡すのは、パフォーマンスの面でも、管理の面でもおすすめしかねるので、スタイルを別に定義し className でスタイル名を指定するのが一般的です。

 <p className="title">Color</p>

Composable の場合、デザインが自由自在な Web とは少し事情が異なります。というのも、Android というプラットフォームの上で動く以上、Android の世界観を反映したデザインにする必要性があるからです。この場合、テーマ機能を使ってデザインを統一するのが一般的なようです。

https://developer.android.com/jetpack/compose/themes/material

これは MaterialTheme Composalbe を使うことになると思います。全体にテーマを当てたいので、最もトップの階層で MaterialTheme Composable を置いて、その下に様々な Composable を置いていく感じです。以下の例では、isSystemInDarkTheme() を使って、ダークモード中であれば暗い色を指定するようにしています。

 val Teal200 = Color(0xFF03DAC5)
 
 val Indigo200 = Color(0xFF9FA8DA)
 val Indigo500 = Color(0xFF3F51B5)
 val Indigo700 = Color(0xFF303F9F)
 val BlueGray50 = Color(0xFFECEFF1)
 val BlueGray900 = Color(0xFF263238)
 
 private val DarkColorPalette = darkColors(
     primary = Indigo200,
     primaryVariant = Indigo700,
     secondary = Teal200,
     background = BlueGray900
 )
 
 private val LightColorPalette = lightColors(
     primary = Indigo500,
     primaryVariant = Indigo700,
     secondary = Teal200,
     background = BlueGray50
 )
 
 @Composable
 fun TryJetpackComposeTheme(
     darkTheme: Boolean = isSystemInDarkTheme(),
     content: @Composable () -> Unit
 ) {
     val colors = if (darkTheme) {
         DarkColorPalette
     } else {
         LightColorPalette
     }
 
     MaterialTheme(
         colors = colors,
         typography = Typography,
         shapes = Shapes,
         content = content
     )
 }
 
 @Composable
 fun TryJetpackComposeApp() {
     TryJetpackComposeTheme {
         val navController = rememberNavController()
         val items = listOf(Screen.Home.name, Screen.Stamp.name)
 
         Scaffold(...) {
   ....

従来では、色は colors.xml に記述していって、それを各地で使うのが一般的でしたが、今回からは変数で色を定義のが推奨されています。

所感

やっぱり宣言的 UI ということで React の知識と結びつけることができるものになっています。Android 固有のライフサイクルを意識する場面は圧倒的に少なくなり、React と同じようなライフサイクルを意識すれば良くなったのは嬉しいです。

コードを書く量も削減されていて、昔のように Activity や Fragment を用意して、XML にレイアウトをかき、DataBinding で bind して、リストの場合は RecyclerView の諸々のコードを用意して...という手間が一挙に無くなったのが嬉しいです。カスタム View 的なものも作りやすくなりました。開発に時間がかかるのが Android のネックな部分だなあと思っていたのですが、その弱点がなくなった印象です。

また Google 公式でドキュメントやコードラボが充実しているため、確かな知識を素早くつけることができる点も魅力です。