🐜

React NativeのNative Module化について(Android)

React NativeのNative Module化について(Android)

こんにちは! アルダグラムで、サーバーサイド寄りのエンジニアをしている内倉です。

KANNA のネイティブアプリは、React Native で作られているのですが

諸事情により、カメラの機能だけ Native Module で実装することになりました。

前回の記事では、@WatanabeKoki が iOS 版実装のさわり部分を紹介してくれたので
https://zenn.dev/aldagram/articles/e74512b3747a3b

私の担当する Android 版のほうも、やっていきたいと思います💪

概要は↓こんな感じ。

1. Kotlin を使う準備

せっかくなら Kotlin で書きたいので、先に Kotlin を使うための設定をしていきます。

build.gradle

buildscript {
    ext {
        ...
        kotlinVersion = "1.6.10"
    }
    ...
    {
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
    }
}

app/build.gradle

apply plugin: "kotlin-android"
apply plugin: "kotlin-android-extensions"

Java のファイルと Kotlin のファイルを分けて置きたい派の人は ↓ も追加しましょう!

android {
    ...
    sourceSets {
        main.java.srcDirs += 'src/main/kotlin'
    }
}

2. NativeModules を React Native から呼び出してみる

カメラの前に、とりあえず簡単なコンポーネントを作って React Native 側から呼び出してみたいと思います。

(1) Layout ファイル作成

src/main/res/layout に、適当にファイルを作ります。

後々、色々詰め込みたいので layout ファイルでやってしまいますが、動的にやってもOKです。

src/main/res/layout/layout_my_view.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/blue" >
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textColor="@color/white"
        android:textSize="40dp"
	android:text="kokodayo" />
</FrameLayout>

(2) View 的なクラス作成

src/main/java/xxx の下に、適当なパッケージを切って、View となるクラスを作っていきます。

MyView.kt

class MyView : FrameLayout {
        val TAG = "MyView"
        private var context: ThemedReactContext? = null

        constructor(context: ThemedReactContext) : super(context) {
            this.context = context

            var layout: FrameLayout = LayoutInflater.from(context).inflate(R.layout.layout_my_view, this, true) as FrameLayout
        }
}

(3) ViewManager クラス作成

React Native からブリッジ経由で呼び出しがあった際に、対応するクラスを作ります。

MyViewManager.kt

class MyViewManager : SimpleViewManager<MyView>() {
    override fun getName(): String {
        // ReactNative から呼び出すときのコンポーネント名
        return "MyView"
    }

    override fun createViewInstance(reactContext: ThemedReactContext): MyView {
        // ↑で呼び出されたときに、ブリッジを通じて React Native へ送り返すインスタンスを返す
        return MyView(reactContext)
    }
}

(4) Package クラス作成

(2)(3) で作ったパッケージを管理するクラスを作成して

MainApplication.java の getPackages() メソッド内で登録します。

MyPackage.kt

class MyPackage : ReactPackage {
    override fun createNativeModules(reactContext: ReactApplicationContext): MutableList<NativeModule> {
        return Collections.emptyList<NativeModule>()
    }

    override fun createViewManagers(reactContext: ReactApplicationContext): MutableList<ViewManager<View, ReactShadowNode<*>>> {
        return mutableListOf(
            MyViewManager() as ViewManager<View, ReactShadowNode<*>>
        )
    }
}

MainApplication.java

public class MainApplication extends Application implements ReactApplication {
    ...

    @Override
    protected List<ReactPackage> getPackages() {
        List<ReactPackage> packages = new PackageList(this).getPackages();
        packages.add(new MyPackage());  // ← ここを追加
        return packages;
    }
}

(5) React Native から呼び出し

最後に、React Native から呼び出します。

src/ui/organisms/NativeView.tsx

import { requireNativeComponent } from 'react-native'

// ViewManager.getName で設定したのと同じ値だよ
export default requireNativeComponent('MyView')

あとは、↑を好きなとこで import して使うだけ!

src/ui/screens/xxx.tsx

import { NativeView } from '@ui/organisms/NativeView'

...
const xxx: FC<xxxProps> = ({ navigation, route }) => {
    ...
    return (
        <View>
          ...
          <NativeView style={{ width: '100%', height: '100%' }} />
          ...
        </View>
    )
}

React Native から呼び出すときに、style で表示範囲を指定するのがポイントです。

表示範囲の指定がないと、コンポーネントは呼ばれるけど何も表示されません。

ここまできたら、npm run android すると↓こんな画面が表示されるはずです。

3. カメラプレビューを実装する

では、先程作った MyView をカメラプレビューに改造していきましょう!

今回は CameraX を使います。

(1) 依存関係を追加

CameraX で、一部 Java8 のメソッドを使っているので、compileOptions も設定します。

app/build.gradle

androd {
    ...
    buildTypes {
        ...
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

dependencies {
    ...
    def camerax_version = "1.1.0-beta02"
    implementation "androidx.camera:camera-core:${camerax_version}"
    implementation "androidx.camera:camera-camera2:${camerax_version}"
    implementation "androidx.camera:camera-lifecycle:${camerax_version}"

    implementation "androidx.camera:camera-view:${camerax_version}"
    implementation "androidx.camera:camera-extensions:${camerax_version}"
}

(2) Manifest ファイルに、カメラのパーミッションを追加

<manifest xmlns:android="http://schemas.android.com/apk/res/android" ...>
    ...
    <uses-permission android:name="android.permission.CAMERA" />
    ...
</manifest>

(3) layout ファイルの修正

TextView を消して、オレンジの部分を追加します。

src/main/res/layout/layout_my_view.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/blue" >
    <androidx.camera.view.PreviewView
        android:id="@+id/camera_preview_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</FrameLayout>

(4) MyView 修正

まずは公式のとおりにやっていきます。

a. ProcessCameraProvider を取得して初期化する。

startCamera() は、カメラ以外の準備が整ったら constructor から呼ぶ感じです。

bindPreview(cameraProvider) は、この次に実装するのでまだなくて大丈夫です。
private lateinit var cameraProviderFuture : ListenableFuture<ProcessCameraProvider>
...

private fun startCamera() {
    cameraProviderFuture = ProcessCameraProvider.getInstance(this.context as Context)
    cameraProviderFuture.addListener(Runnable {
        try {
            val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
            bindPreview(cameraProvider) // これから作るね
        } catch (e: Exception){
            Log.e(TAG, e.stackTraceToString())
        }
    }, ContextCompat.getMainExecutor(this.context as Context))
}

b. bindPreview() 作成

実際に、カメラの準備をしていきます。

CameraX では、Preview、Image analysis、Image capture、Video capture の4つの機能( usecase )があり、必要なものだけを選んで登録します。

今回は、プレビューするだけなので↓こんなかんじ。

fun bindPreview(cameraProvider: ProcessCameraProvider) {
    // 使いたい usecase 作成
    val preview = Preview.Builder()
        .build()
        .also {
            it.setSurfaceProvider(previewView.surfaceProvider)
        }
    // 使いたいカメラを指定
    val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
    try {
        // カメラと usecase をバインド
        cameraProvider.unbindAll()
        camera = cameraProvider.bindToLifecycle(
            this, cameraSelector, preview
        )
    } catch (exc: Exception) {
        Log.e(TAG, "Use case binding failed", exc)
    }
}

c. そして MyView 自体に LifecycleOwner インターフェイスを登録

class MyView : FrameLayout**, LifecycleOwner** {
    ...
    private lateinit var lifecycleRegistry : LifecycleRegistry
    ...

    constructor(context: ThemedReactContext) : super(context) {
        ...
        lifecycleRegistry = LifecycleRegistry(this)
        context.addLifecycleEventListener(object : LifecycleEventListener {
            override fun onHostResume() {
                lifecycleRegistry.currentState = Lifecycle.State.RESUMED
            }

            override fun onHostPause() {
                lifecycleRegistry.currentState = Lifecycle.State.CREATED
            }

            override fun onHostDestroy() {
                lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
            }
        })
    }

    override fun getLifecycle(): Lifecycle {
        return lifecycleRegistry
    }
}

d. root の FrameLayout のサイズ設定

Native Module で実装する場合、ここまでだとまだ表示されません!

View が実際に追加されてからサイズを設定しないと表示できないようなので、指定を追加します。

...
private lateinit var previewView: PreviewView

constructor(context: ThemedReactContext) : super(context) {
    ...
    var layout = LayoutInflater.from(context).inflate(R.layout.layout_my_view, this, true) as FrameLayout
    
    previewView = layout.findViewById<PreviewView>(R.id.camera_preview_view)
    previewView.setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
        override fun onChildViewRemoved(parent: View?, child: View?) = Unit
        override fun onChildViewAdded(parent: View?, child: View?) {
            parent?.measure(
                View.MeasureSpec.makeMeasureSpec(measuredWidth, View.MeasureSpec.EXACTLY),
                View.MeasureSpec.makeMeasureSpec(measuredHeight, View.MeasureSpec.EXACTLY)
            )
            parent?.layout(0, 0, parent.measuredWidth, parent.measuredHeight)
        }
    })
}

↓ a 〜 d まで実装した MyView がこちらです

class MyView : FrameLayout, LifecycleOwner {
    val TAG = "MyView"

    private lateinit var context: ThemedReactContext

    private lateinit var cameraProviderFuture : ListenableFuture<ProcessCameraProvider>
    private lateinit var lifecycleRegistry : LifecycleRegistry
    private lateinit var previewView: PreviewView

    private var camera: Camera? = null

    constructor(context: ThemedReactContext) : super(context) {
        this.context = context

        var layout = LayoutInflater.from(context).inflate(R.layout.layout_my_view, this, true) as FrameLayout
        previewView = layout.findViewById<PreviewView>(R.id.camera_preview_view)
        previewView.setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
            override fun onChildViewRemoved(parent: View?, child: View?) = Unit
            override fun onChildViewAdded(parent: View?, child: View?) {
                parent?.measure(
                    View.MeasureSpec.makeMeasureSpec(measuredWidth, View.MeasureSpec.EXACTLY),
                    View.MeasureSpec.makeMeasureSpec(measuredHeight, View.MeasureSpec.EXACTLY)
                )
                parent?.layout(0, 0, parent.measuredWidth, parent.measuredHeight)
            }
        })

        startCamera()
        
        lifecycleRegistry = LifecycleRegistry(this)
        context.addLifecycleEventListener(object : LifecycleEventListener {
            override fun onHostResume() {
                lifecycleRegistry.currentState = Lifecycle.State.RESUMED
            }

            override fun onHostPause() {
                lifecycleRegistry.currentState = Lifecycle.State.CREATED
            }
    
            override fun onHostDestroy() {
                lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
            }
        })
    }

    private fun startCamera() {
        cameraProviderFuture = ProcessCameraProvider.getInstance(this.context as Context)

        cameraProviderFuture.addListener(Runnable {
            try {
                val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
                bindPreview(cameraProvider)
            } catch (e: Exception){
                Log.e(TAG, e.stackTraceToString())
            }
        }, ContextCompat.getMainExecutor(this.context as Context))
    }

    fun bindPreview(cameraProvider: ProcessCameraProvider) {
        val preview = Preview.Builder()
            .build()
            .also {
                it.setSurfaceProvider(previewView.surfaceProvider)
            }
        val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
        try {
            cameraProvider.unbindAll()
            camera = cameraProvider.bindToLifecycle(
                this, cameraSelector, preview
            )
        } catch (exc: Exception) {
            Log.e(TAG, "Use case binding failed", exc)
        }
    }

    override fun getLifecycle(): Lifecycle {
        return lifecycleRegistry
    }
}

無事、プレビューが表示されました!やったね🥳

アルダグラム Tech Blog

Discussion