🌊

【React Native】React Native で Jetpack Compose を使ってみる

2023/03/14に公開

こんにちは!アルダグラムでエンジニアをしている渡邊です!

先日 React Native の Native UI Components で Android のネイティブのビューを表示する という記事を投稿しましたが、今回はその応用として Jetpack Compose を導入してみました!

試しに弊社のアプリの KANNA に組み込んでみましたが問題が発生したため、その際の対応も含めて共有できればと思っています。

※ もし今回のソースコードを使用される際はあくまで自己責任でお願いします

Jetpack Compose とは

Jetpack Compose とは Android で UI を構築するためのライブラリです。 React と同様宣言的 UI が特徴となっており、UI が簡潔かつ直感的に記述できるようになります。

JetpackCompose.kt
@Composable
fun JetpackCompose() {
    Card {
        var expanded by remember { mutableStateOf(false) }
        Column(Modifier.clickable { expanded = !expanded }) {
            Image(painterResource(R.drawable.jetpack_compose))
            AnimatedVisibility(expanded) {
                Text(
                    text = "Jetpack Compose",
                    style = MaterialTheme.typography.bodyLarge
                )
            }
        }
    }
}

上記は Jetpack Compose のドキュメントに記載されているサンプルになりますが、React を使ったことがある方であれば何となく読むこともできるのではないでしょうか。

Jetpack Compose を使うための手順

React Native で Jetpack Compose を導入するための手順について見ていきます。

  1. Kotlin の導入
  2. Jetpack Compose の導入
  3. Jetpack Compose での UI 作成

Kotlin の導入

Jetpack Compose を使うには Kotlin が必須になります。 そのため Kotlin を導入していない場合は、まず Kotlin を導入する必要があります。

android/build.gradle
buildscript {
    dependencies {
        ...
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10") // <- この行を追加
    }
}
android/app/build.gradle
apply plugin: "com.android.application"
apply plugin: "kotlin-android" // <- この行を追加

後述する Compose コンパイラのバージョンによって、使用可能な Kotlin のバージョンは固定されます。

Compose コンパイラのバージョンと対応する Kotlin のバージョンの表が こちらのドキュメント に記載されているので参考にしてください。

Jetpack Compose の導入

続いて Jetpack Compose の導入になります。 まずは android/app/build.gradle に以下を追加します。

android/app/build.gradle
android {
    ...
      
    buildFeatures {
        compose true
    }

    composeOptions {
        // Compose コンパイラのバージョン
        kotlinCompilerExtensionVersion = "1.4.3"
    }
}

dependencies {
    // Compose のバージョン管理は BOM を使うと便利
    // https://developer.android.com/jetpack/compose/bom/bom
    def composeBom = platform('androidx.compose:compose-bom:2023.01.00')
    implementation composeBom
  
    // Material Design 2
    implementation 'androidx.compose.material:material'
    
    // Android Studio Preview support
    implementation 'androidx.compose.ui:ui-tooling-preview'
    debugImplementation 'androidx.compose.ui:ui-tooling'
}

上記で導入しているライブラリは最低限のものになっています。 Compose で使えるアイコンのライブラリなど、他にも導入したい場合は こちらのドキュメント を参考にしていただくのがよいかと思います。

また注意点として上記に記載した Compose のバージョンを使う場合、compileSdkVersion が33以降である必要があります。 設定自体は android/app/build.gradle にありますが、React Native アプリを初期作成した時には android/build.gradle に変数定義されているのでそちらを更新する必要があるかもしれません。

android/build.gradle
buildscript {
    ext {
        ...
        compileSdkVersion = 33 // <- 33 に更新する
        targetSdkVersion = 31
        ...
    }
    ...
}

これで Jetpack Compose の導入は完了になります。

Jetpack Compose での UI 作成

続いて Jetpack Compose で UI を作成し、それを表示してみたいと思います。 Android のネイティブのビューを表示するためには Native UI Components の機能を使う必要があります。 こちらについては先日投稿した React Native の Native UI Components で Android のネイティブのビューを表示する の記事を参考にしていただけたらと思います。

まずは Android 側で Compose を使うための View を作成します。

MyComposeView.kt
class MyComposeView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr) {
    @Composable
    override fun Content() {
        // このメソッド内に Compose のコードが書ける
        Text(text = "Hello, Jetpack Compose!")
    }
}

AbstractComposeView を継承した View を用意し、Content メソッドをオーバーライドします。

Compose で UI のコードを書く際に @Composable アノテーションが付いたメソッドは @Composable アノテーションが付いたメソッド内でしか呼び出せないというルールがあります。
Content メソッドは @Composable アノテーションが付いているので、このメソッド内に UI のコードを書いていく形になります。

続いて SimpleViewManager を継承した MyComposeViewManager を作ります。

MyComposeViewManager.kt
class MyComposeViewManager : SimpleViewManager<MyComposeView>() {
    override fun getName(): String {
        // React Native 側で View を参照する際の名前
        return "MyComposeView"
    }

    override fun createViewInstance(context: ThemedReactContext): MyComposeView {
        return MyComposeView(context)
    }
}

createViewInstance メソッドで先ほど作成した MyComposeView を返すようにします。

続いて作成した MyComposeViewManager を登録します。

MyPackage.kt
class MyPackage : ReactPackage {
    override fun createNativeModules(context: ReactApplicationContext): List<NativeModule> {
        return emptyList()
    }

    override fun createViewManagers(context: ReactApplicationContext): List<ViewManager<*, *>> {
        return listOf(MyComposeViewManager())
    }
}
MainApplication.java
public class MainApplication extends Application implements ReactApplication {

  private final ReactNativeHost mReactNativeHost =
      new ReactNativeHost(this) {
        @Override
        public boolean getUseDeveloperSupport() {
          return BuildConfig.DEBUG;
        }

        @Override
        protected List<ReactPackage> getPackages() {
          @SuppressWarnings("UnnecessaryLocalVariable")
          List<ReactPackage> packages = new PackageList(this).getPackages();
          packages.add(new MyPackage()); // <- この行を追加
          return packages;
        }

  ...

これで Android 側の設定は完了したので、続いて React Native 側になります。

まず MyComposeView をコンポーネントとして使えるように定義します。

MyComposeView.ts
import { requireNativeComponent, ViewStyle } from 'react-native'

type MyComposeViewProps = Readonly<{
  style: ViewStyle
}>

export const MyComposeView =
  requireNativeComponent<MyComposeViewProps>('MyComposeView')

そして定義した MyComposeView を表示するようにすれば完成です!

App.tsx
import { StyleSheet } from 'react-native'
import { MyComposeView } from './MyComposeView'

const App = () => {
  return (
    <MyComposeView style={styles.compose} />
  )
}

const styles = StyleSheet.create({
  compose: {
    flex: 1
  }
});

export default App

これで React Native で Compose を使って UI を描画することができました。

React Native から変数を渡して Compose の表示を更新する

もし React Native で UI の表示に必要な状態を管理している場合、React Native から Android のネイティブに対して状態を渡して表示を更新させる必要があります。

例として以下のように React Native 側でボタンを表示し、ボタンをタップするとカウンターが1つずつ増えていく場合を考えます。

App.tsx
const App = () => {
  const [count, setCount] = useState(0)

  return (
    <>
      <MyComposeView style={styles.compose} />
      <Button 
        title='Increment' 
        onPress={() => setCount(prevCount => prevCount + 1)}
      />
    </>
  )
}

このカウントの値を Compose で表示させてみたいと思います。 React Native 側でコンポーネントの props として設定された値を受け取るために、まずは先ほど作成した MyComposeViewManager を変更します。

MyComposeViewManager.kt
class MyComposeViewManager : SimpleViewManager<MyComposeView>() {
    ...

    @ReactProp(name = "count")
    fun setCount(view: MyComposeView, count: Int) {
        view.setCount(count)
    }
}

props を受け取るために上記のように @ReactProp アノテーションを付与したセッターメソッドを用意します。 今回数値を受け取るので引数の型は Int にしています。

MyComposeViewsetCount メソッドを用意し、そこに値を渡しています。 続いて MyComposeView を変更します。

MyComposeView.kt
class MyComposeView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr) {

    private val count: MutableState<Int> = mutableStateOf(0) // ①

    @Composable
    override fun Content() {
        // ② で値が更新されると UI が再レンダリングされ表示が更新される
        Text(text = "count = ${count.value}")
    }

    fun setCount(count: Int) {
        this.count.value = count // ②
    }
}

ここで重要なのは ① の部分です。 Compose では状態が変わると UI が再レンダリングされるようになっており、この状態は State の変数で管理されます。

State は読み取り専用ですが、MutableState は値の更新が可能になっており、mutableStateOf というメソッドで変数の作成を行います。

なので②の部分で MutableState の値が更新されると、Content メソッド内で count を参照しているため UI が再レンダリングされて表示が更新されるようになります。

ちなみにこの MutableState はいくつか書き方があり、以下のように書くこともできます。
(詳細は こちらのドキュメント を参照)

// Kotlin の Delegated Propeties の機能を使っている
// 利用する際には以下のインポート文が必要
// import androidx.compose.runtime.getValue
// import androidx.compose.runtime.setValue
private var count: Int by mutableStateOf(0)

// Kotlin の Destructuring Declarations の機能を使っている
private val (count, setCount) = mutableStateOf(0)

続いて React Native 側の変更です。 MyComponentView の props に count を設定できるように型定義します。

MyComponentView.ts
type MyComposeViewProps = Readonly<{
  count: number  // <- この行を追加
  style: ViewStyle
}>

最後に MyComponentView の props に count を渡すようにすれば完成です。

App.tsx
const App = () => {
  const [count, setCount] = useState(0)

  return (
    <>
      <MyComposeView
        count={count}
        style={styles.compose}
      />
      <Button 
        title='Increment' 
        onPress={() => setCount(prevCount => prevCount + 1)}
      />
    </>
  )
}

発生した問題

最初弊社のアプリに試しに組み込む際に、前述の MyComposeViewManager で設定するビューを ComposeView を使って試しました。

すると Compose を使った画面の表示時に以下のような例外が発生しました。

java.lang.IllegalStateException: Cannot locate windowRecomposer; View androidx.compose.ui.platform.ComposeView{65a64d6 V.E...... ......ID 0,0-0,0 #395} is not attached to a window
    at androidx.compose.ui.platform.WindowRecomposer_androidKt.getWindowRecomposer(WindowRecomposer.android.kt:294)
    at androidx.compose.ui.platform.AbstractComposeView.resolveParentCompositionContext(ComposeView.android.kt:240)
    at androidx.compose.ui.platform.AbstractComposeView.ensureCompositionCreated(ComposeView.android.kt:247)
    at androidx.compose.ui.platform.AbstractComposeView.onMeasure(ComposeView.android.kt:284)
    at android.view.View.measure(View.java:26358)
    at com.facebook.react.uimanager.NativeViewHierarchyManager.updateLayout(NativeViewHierarchyManager.java:189)
    at com.swmansion.reanimated.layoutReanimation.ReanimatedNativeHierarchyManager.updateLayout(ReanimatedNativeHierarchyManager.java:274)
    at com.facebook.react.uimanager.UIViewOperationQueue$UpdateLayoutOperation.execute(UIViewOperationQueue.java:169)
    at com.facebook.react.uimanager.UIViewOperationQueue$1.run(UIViewOperationQueue.java:915)
    at com.facebook.react.uimanager.UIViewOperationQueue.flushPendingBatches(UIViewOperationQueue.java:1026)
    at com.facebook.react.uimanager.UIViewOperationQueue.access$2600(UIViewOperationQueue.java:47)
    at com.facebook.react.uimanager.UIViewOperationQueue$DispatchUIFrameCallback.doFrameGuarded(UIViewOperationQueue.java:1086)
    at com.facebook.react.uimanager.GuardedFrameCallback.doFrame(GuardedFrameCallback.java:29)
    at com.facebook.react.modules.core.ReactChoreographer$ReactChoreographerDispatcher.doFrame(ReactChoreographer.java:175)
    at com.facebook.react.modules.core.ChoreographerCompat$FrameCallback$1.doFrame(ChoreographerCompat.java:85)
    at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1229)
    at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1239)
    at android.view.Choreographer.doCallbacks(Choreographer.java:899)
    at android.view.Choreographer.doFrame(Choreographer.java:827)
    at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:1214)
    at android.os.Handler.handleCallback(Handler.java:942)
    at android.os.Handler.dispatchMessage(Handler.java:99)
    at android.os.Looper.loopOnce(Looper.java:201)
    at android.os.Looper.loop(Looper.java:288)
    at android.app.ActivityThread.main(ActivityThread.java:7898)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936)

どうやら ComposeView が Window にアタッチされていなく、例外が発生しているようでした。

これは https://github.com/react-native-community/discussions-and-proposals/issues/446#issuecomment-1009532706 でコメントされている問題と同じで、原因についてもこちらに記載されています。

どうやら ComposeView では onMeasure() のタイミングで Window にアタッチされている必要がありますが、React Native では NativeViewHierarchyManager というクラス内でビューが Window にアタッチされていない状態でビューの measure メソッドを呼んでいるため、Window にアタッチされていない状態でも onMeasure() が呼び出されてしまっているようです。

対応策

上記の例外は ComposeView が CompositionContext というインスタンスを参照できないために発生しているようです。

https://github.com/react-native-community/discussions-and-proposals/issues/446#issuecomment-1010436438 のコメントを参考に、以下のような対策を行いました。

  1. AbstractComposeView を継承したスタブのビューを実装し、ビューを見えない状態にして ReactRootView にあらかじめ addView しておく
  2. AbstractAppComposeView を作成し、Compose での画面表示にはこれを継承したビューを使う
補足

例外が発生する原因は ComposeView が CompositionContext というインスタンスを参照できないために発生しています。
CompositionContext のインスタンスは ComposeView がウィンドウにアタッチされる時に作られますが、先祖のビューですでに作られているものがあればそれが利用されるようになっています。
なので 1 であらかじめ addView しておくことにより CompositionContext のインスタンスを作る目的でこの処理を行なっています

1 については、まず以下のようなビューを作成します。

StubComposeView.kt
class StubComposeView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr) {

    init {
        isVisible = false
    }

    @Composable
    override fun Content() {
    }
}
MainActivity.java
@Override
protected ReactActivityDelegate createReactActivityDelegate() {
    return new ReactActivityDelegateWrapper(this,
        new ReactActivityDelegate(this, getMainComponentName()) {
            @Override
            protected ReactRootView createRootView() {
                final ReactRootView view = super.createRootView();
                view.addView(new StubComposeView(MainActivity.this)); // StubComposeView を addView する
                return view;
            }
        }
    );
}

2 については、まず AbstractAppComposeView を作成します。

AbstractAppComposeView.kt
abstract class AbstractAppComposeView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr) {

    init {
        // [WORKAROUND]
        // https://github.com/react-native-community/discussions-and-proposals/issues/446#issuecomment-1009532706
        // ViewManager の createViewInstance() メソッドで ComposeView のインスタンスを返すと、
        // 上記に記載されているものと同じエラーが表示されてしまう.
        val compositionContext = findViewTreeCompositionContext()
            ?: context.findActivity()?.findViewById<ViewGroup>(android.R.id.content)
                ?.children
                ?.mapNotNull { it.findViewTreeCompositionContext() }
                ?.firstOrNull()
        setParentCompositionContext(compositionContext)
    }
}

そして Compose を使う画面ではこのビューを継承したクラスを作成して、Compose のコードを書いていきます。

MyComposeView.kt
class MyComposeView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AbstractAppComposeView(context, attrs, defStyleAttr) {
    @Composable
    override fun Content() {
        ...
    }
}

これで Compose の画面を表示しても例外が発生しなくなります。
この対策を行うことで特に追加で問題は発生していませんが、正しい対策かどうかは不明のため参考にする際にはあくまで自己責任でお願いします。

以上が行った対策になります。

最後に

ちなみに先ほどの例外については正直発生条件がよくわかりませんでした。
簡単なサンプルプロジェクトを作ってみて試しても例外が発生しなかったので、React Native で使っているライブラリなども関係しているかもしれませんが、どなたかの参考になれば幸いです!

アルダグラム Tech Blog

Discussion