今天我们介绍在React Native新架构中如何自定义Turbo Native Module扩展API。本系列基于React Native 0.73.4版本,从一名Android开发者的视角进行介绍。本系列介绍的内容默认读者对React Native有一定的了解,对基础的开发内容不再赘述。
在老的架构中,自定义API的能力被称为Native Modules。在新架构中,自定义API的能力被称为Turbo Native Modules,本文的主要内容参考自React Native开源代码中关于Turbo Native Modules的Android和JS部分的介绍,并结合一个实际的例子,会在其中穿插一下自己遇到的问题和理解。
1.为什么需要Turbo Native Module
其实,React Native已经给我们提供了常见的API,例如打开链接、网络请求、设备振动等API,所有的API我们可以在官方文档中查询到。但有一些场景需要我们有一些诉求,比如从相册选择图片,获取定位或者是进行复杂的图片处理等,这时候我们就需要利用Turbo Module将平台的(Android、iOS、HarmonyOS)能力桥接到JS侧,供我们在JS侧调用。一般情况下,我们使用React Native编写JS代码都是运行在Andorid和iOS双平台上,所以我们两端都需要对接响应的能力。但本文只会介绍Android和JS侧的实现,不包含iOS侧的实现。
2.如何开发一个Turbo Native Module
下面我们以一个具体的例子,详细介绍如何实现一个Turbo Native Module来自定义API提供给JS侧使用。
2.1.定义API
这里我们定义实现2个API用于以同步和异步的方式测量一段文本的宽度,定义如下:
1.API名称:
-
measureTextAsync
-
measureTextSync
2.参数定义:
对象object,类型TextInfo。
参数名称 | 参数类型 | 参数说明 |
---|---|---|
text | string | 需要测量的文本内容 |
fontSize | number | 字体大小 |
fontFamily | string | 字体名称 |
3.返回值
对象object,类型MeasureResult。
返回值名称 | 返回值类型 | 返回值说明 |
---|---|---|
width | number | 文本宽度 |
2.2.创建模块文件夹
由于我们希望我们的模块可以独立分发给任意的React Native项目使用,所以我们需要单独创建一个模块文件夹编写相关的代码,这个模块文件夹中包含JS、Android、iOS代码实现(当然,我们本次对于iOS代码逻辑不进行实现)。
我们创建一个模块文件夹RTNTextHelper
,其中RTN代表React Native。在其中创建js、android、ios 3个目录分别代表对应的代码实现,最终目录结构如下:
shell
RTNTextHelper
├── android
├── ios
└── js
2.3.使用TypeScript定义API接口代码
接下来我们首先根据我们之前的API定义来定义JS侧接口代码,这部分代码是最终需要我们在React Native业务代码中使用的对外接口代码,放在js目录下,然后才有后面Andorid、iOS分别进行具体实现。
新架构要求我们必须使用TypeScript或者Flow进行定义,这是为了接口定义中出入参类型是明确的,方便后面Codegen依据此进行模版代码生成。同时还有2个要求。
- 该文件必须 命名为
Native<MODULE_NAME>
,使用 Flow 时扩展名为.js
或.jsx
,使用 TypeScript 时扩展名为.ts
或.tsx
。 Codegen 将仅查找与此模式匹配的文件。 - 该文件必须导出TurboModuleRegistrySpec对象。
我们这里根据我们前面的API定义进行相关代码编写如下,具体可以查看代码中的注释:
typescript
// 文件名:NativeRTNTextHelper.ts
import { TurboModule, TurboModuleRegistry } from "react-native";
export type TextInfo = {
text: string
fontSize: number
fontFamily?: string
}
export type MeasureResult = {
width: number
}
export interface Spec extends TurboModule {
// 异步方法
measureTextAsync(textInfo: TextInfo): Promise<MeasureResult>;
// 同步方法
measureTextSync(textInfo: TextInfo): MeasureResult;
}
export default TurboModuleRegistry.get<Spec>("RTNTextHelper") as Spec | null;
需要注意以下几点:
- 模块定义比如是一个名称为Spec的interface
- 必须继承自TurboModule
- TurboModuleRegistry中get需要填写的模块名为我们之前定义的模块名(但需要注意不是文件名,不包含
Native
前缀),这行代码执行的时候,会加载我们的模块
2.4.添加模块配置
2.4.1.添加package.json
因为我们最终分发的模块是一个包含了Android、iOS、JS代码的npm包,然后用户通过yarn install进行安装,所以我们需要给我们的模块编写一个package.json文件(注意,package.json文件放在模块根目录下,不是js目录下),文件的具体内容如下:
JSON
{
"name": "rtn-texthelper",
"version": "0.0.1",
"description": "Measure text with Turbo Native Modules",
"react-native": "js/index",
"source": "js/index",
"files": [
"js",
"android",
"ios",
"rtn-calculator.podspec",
"!android/build",
"!ios/build",
"!**/__tests__",
"!**/__fixtures__",
"!**/__mocks__"
],
"keywords": [
"react-native",
"ios",
"android"
],
"repository": "https://github.com/linkecoding/RTNTextHelper",
"author": "Sidon",
"license": "MIT",
"bugs": {
"url": "https://github.com/linkecoding/RTNTextHelper/issues"
},
"homepage": "https://github.com/linkecoding/RTNTextHelper#readme",
"devDependencies": {},
"peerDependencies": {
"react": "*",
"react-native": "*"
},
"codegenConfig": {
"name": "RTNTextHelperSpec",
"type": "modules",
"jsSrcsDir": "js",
"android": {
"javaPackageName": "com.sample.rtntexthelper"
}
}
}
这里有几项配置需要说明:
- name这个是最终发布的npm包的名称
- react-native和source指向npm包中js代码入口对应的文件
- peerDependencies中不指定具体的版本号,依赖具体业务工程中的版本号进行确定
- codegenConfig这个是最关键的配置
- name与最终生成的Android/iOS模板代码的名称有关,这里一般为模块名称添加Spec后缀
- jsSrcsDir是codegen寻找模块定义的API代码(NativeRTNTextHelper.ts文件)时查找的目录名称,我们这里是js目录
- android.javaPackageName是定义最终生成的Android模板代码的包名称
2.4.2.添加build.gradle配置
除了配置npm包模块外,我们需要配置android文件夹中的信息,Android代码是以module形式存在的,所以必然存在build.gradle文件。
这个文件的代码,大部分也是固定的,内容如下(由于我们后面逻辑使用Kotlin编写,所以这里的插件中引入了kotlin相关的插件):
groovy
// android/build.gradle
buildscript {
ext.safeExtGet = {prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
repositories {
google()
gradlePluginPortal()
}
dependencies {
classpath("com.android.tools.build:gradle:7.3.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.22")
}
}
apply plugin: 'com.android.library'
apply plugin: 'com.facebook.react'
apply plugin: 'org.jetbrains.kotlin.android'
android {
compileSdkVersion safeExtGet('compileSdkVersion', 33)
namespace "com.sample.rtntexthelper"
}
repositories {
mavenCentral()
google()
}
dependencies {
implementation 'com.facebook.react:react-native'
}
这里需要注意的有几个点:
- android配置块中
- compileSdkVersion需要根据你的实际情况进行配
- namespace是Android Gradle Plugin 7.3.0版本提出的一个新的配置字段,主要用于为编译时自动生成的R类和BuildConfig类指定包名称,这里我们设置为与package.json中配置的
javaPackageName
字段的值保持一致。
2.4.3.编写Android代码ReactPackage类
为了后面使用Codegen生成代码,我们还需要在对应的package目录下使用Java或者Kotlin写一个类继承自TurboReactPackage类,但我们可以先将实现留空,这一步只是为了Codegen可以正常帮我们生成一部分模板代码。
我们在android目录下创建目录src/main/java/com/sample/rtntexthelper
目录,然后在文件夹中创建一个Kotlin类(当然你也可以使用Java编写代码)RTNTextHelperPackage.kt
,具体内容如下:
package com.sample.rtntexthelper;
import com.facebook.react.TurboReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfoProvider
class RTNTextHelperPackage : TurboReactPackage() {
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = null
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider? = null
}
除了package名称和类名,其他代码都是固定写法。
2.5.使用Codegen生成代码
接下来终于到了使用Codegen生成模板代码的步骤了,因为无论是编写Turbo Native Module还是自定义组件都存在很多模板代码,所以React Native官方使用Codegen来帮助我们生成一部分代码。
要使用Codegen,我们需要把我们的模块添加到一个完整的React Native App工程中,我们这里命名为ReactNativeSample
,我们的自定义Turbo Native Module的文件夹与这个工程平级。
具体如何创建一个React Native App工程可以查看上一篇文章React Native新架构系列-新架构介绍。
这里提供2个库的Git下载地址,可以直接下载体验:
ReactNativeSample:https://github.com/linkecoding/ReactNativeSample
RTNTextHelper:https://github.com/linkecoding/RTNTextHelper
我们执行下面的命令来通过Gradle任务来调用Codegen:
cd ReactNativeSample
yarn add ../RTNTextHelper
cd android
./gradlew generateCodegenArtifactsFromSchema
最终我们在这个目录下会获得自动生成的代码ReactNativeSample/node_modules/rtn-texthelper/android/build/generated/source/codegen
,最终生成的代码的目录结构如下:
这里最主要的代码是NativeRTNTextHelperSpec
这个类,它是根据我们JS侧的API定义生成了Android侧的抽象类,内容如下:
java
/**
* This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen).
*
* Do not edit this file as changes may cause incorrect behavior and will be lost
* once the code is regenerated.
*
* @generated by codegen project: GenerateModuleJavaSpec.js
*
* @nolint
*/
package com.sample.rtntexthelper;
import com.facebook.proguard.annotations.DoNotStrip;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.turbomodule.core.interfaces.TurboModule;
import javax.annotation.Nonnull;
public abstract class NativeRTNTextHelperSpec extends ReactContextBaseJavaModule implements TurboModule {
public static final String NAME = "RTNTextHelper";
public NativeRTNTextHelperSpec(ReactApplicationContext reactContext) {
super(reactContext);
}
@Override
public @Nonnull String getName() {
return NAME;
}
@ReactMethod
@DoNotStrip
public abstract void measureTextAsync(ReadableMap textInfo, Promise promise);
@ReactMethod(isBlockingSynchronousMethod = true)
@DoNotStrip
public abstract WritableMap measureTextSync(ReadableMap textInfo);
}
这里有几个特征:
- 继承了ReactContextBaseJavaModule类并实现了TurboModule接口
- getName返回我们的API模块名称
- 根据我们的API定义生成了2个抽象方法等待我们实现
- 同步方法直接返回结果,异步方法使用方法的第二个参数promise进行返回
这里需要注意的是,Codegen生成的代码,我们不应该提交到Git进行版本管理,我们最终分发的这个npm包中不应该包含这部分代码,自动生成的代码仅用于我们开发库时编译。最终当某个App的代码引入我们的库时,Codegen会在编译过程中自动为我们的库生成代码并将全部代码打入App中。
这里还踩到了另一个坑(没有遇到的可以直接跳过这里),由于我的Gradle在~/.gradle/init.gradle
中重新配置了每个项目的build目录的生成文件位置,如下内容:
groovy
// ~/.gradle/init.gradle文件
gradle.projectsLoaded {
rootProject.allprojects {
buildDir = "/Users/xxx/.gradle-build/${rootProject.name}/${project.name}"
}
}
所以我使用上面的gradle命令生成Codegen代码时,实际的生成位置位于~/.gradle-build/ReactNativeSample/rtn-texthelper/generated/source/codegen
目录下。
2.6.编写具体API实现代码
经过上面的步骤,项目的配置,模板代码都已经生成好了,接下来我们就开始编写代码了,由于我们之前的yarn add ../RTNTextHelper
命令会直接将我们的模块引入到我们的ReactNativeSample工程,所以我们可以直接使用AndroidStudio打开ReactNativeSample工程的android目录,同步代码后就会看到我们的module,如下图:
但这里有个非常坑的点,我们使用yarn add ../RTNTextHelper
这个命令时,它实际上是将我们的RTNTextHelper里面的代码拷贝了一份到ReactNativeSample工程的node_module/rtntexthelper
目录下,我们在Android Studio中看到的是这个目录。
所以当我们在Android Studio里写好了代码后,只要再执行一次yarn add ../RTNTextHelper
,我们的代码就丢失了(因为又重新安装了)。我目前的办法是在Android Studio中写好后,手动拷贝到RTNTextHelper这个模块里。如果有其他优雅的方法可以评论交流。
接下来就是实现我们2个API的具体逻辑了,我们需要定义个RTNTextHelperModule
类,继承自上面自动生成的NativeRTNTextHelperSpec
类,实现其中的2个方法,具体逻辑如下:
kotlin
package com.sample.rtntexthelper
import android.graphics.Paint
import android.graphics.Typeface
import android.text.TextUtils
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.WritableNativeMap
class RTNTextHelperModule(reactContext: ReactApplicationContext) :
NativeRTNTextHelperSpec(reactContext) {
companion object {
const val NAME = "RTNTextHelper"
}
override fun getName() = NAME
override fun measureTextAsync(textInfo: ReadableMap?, promise: Promise?) {
val width = getTextWidth(textInfo)
val result = WritableNativeMap()
result.putDouble("width", width)
promise?.resolve(result)
}
override fun measureTextSync(textInfo: ReadableMap?): WritableMap {
val width = getTextWidth(textInfo)
val result = WritableNativeMap()
result.putDouble("width", width)
return result
}
private fun getTextWidth(textInfo: ReadableMap?): Double {
return textInfo?.let {
val text = it.getString("text")
val fontSize = it.getInt("fontSize")
val fontFamily = it.getString("fontFamily")
val paint = Paint()
paint.textSize = fontSize.toFloat()
if (!TextUtils.isEmpty(fontFamily)) {
paint.typeface = Typeface.create(fontFamily, Typeface.NORMAL)
}
val width = paint.measureText(text)
return@let width.toDouble()
} ?: 0.0
}
}
这里需要注意同步API和异步API对应的方法的返回值写法稍有差异其他没有区别。
还需要注意,我们getName返回的名称,一定要与我们JS侧获取模块时的名称一致,这个名称也会在我们下面注册和查找模块逻辑中用到。
实现完具体逻辑以后,我们还需要将我们的模块进行注册,确保在JS侧调用时可以正确找到我们的模块,我们需要修改RTNTextHelperPackage.kt
这个文件,具体内容如下:
kotlin
package com.sample.rtntexthelper
import com.facebook.react.TurboReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider
class RTNTextHelperPackage : TurboReactPackage() {
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
if (name == RTNTextHelperModule.NAME) {
RTNTextHelperModule(reactContext)
} else {
null
}
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
mapOf(
RTNTextHelperModule.NAME to ReactModuleInfo(
RTNTextHelperModule.NAME,
RTNTextHelperModule.Companion::class.java.simpleName,
false,
false,
false,
true
)
)
}
}
最主要的就是根据getModule的name参数来匹配返回我们自定义的模块。
3.如何使用自定义Turbo Native Module
接下来就可以看一下如何在一个React Native工程中使用我们的模块。其实在之前使用Codegen生成代码时我们就一定用到了这样的方式。
3.1.引入
在本地调试时,我们可以直接通过下面的命令将我们自己实现的模块添加到React Native工程中。
shell
cd ReactNativeSample
yarn add ../RTNTextHelper
这种方式会将我们的模块安装到工程的node_modules目录下。
如果我们将自己开发的模块发布到了npm仓库,也可以直接使用yarn add <npm包名>
,来添加模块,方式几乎是一样的。
3.2.JS调用
由于我们之前已经写了JS侧的代码,我们就可以像使用一个正常的npm包那样使用我们的模块中的JS代码,只是逻辑最终会走到我们Native侧的实现并返回结果,这里给一个简单的使用示例:
jsx
import React, { useEffect } from 'react';
import { SafeAreaView, ScrollView, StatusBar, Text } from 'react-native';
import RTNTextHelper from 'rtn-texthelper/js/NativeRTNTextHelper';
function App(): React.JSX.Element {
useEffect(() => {
const textInfo = {
text: '测试文本',
fontSize: 12,
};
const result = RTNTextHelper?.measureTextSync(textInfo);
console.log('===RTNTextHelper.measureTextSync==', JSON.stringify(result));
RTNTextHelper?.measureTextAsync(textInfo)
.then(res => {
console.log('===RTNTextHelper.measureTextAsync==', JSON.stringify(res));
})
.catch(() => {
// ignore
});
}, []);
return (
<SafeAreaView>
<StatusBar barStyle={'dark-content'} />
<ScrollView contentInsetAdjustmentBehavior="automatic">
<Text>Sample</Text>
</ScrollView>
</SafeAreaView>
);
}
export default App;
然后我们执行下面的命令即可:
shell
# 首先连接好设备
cd ReactNativeSample
yarn android
最后就可以在控制台看到输出日志如下
4.总结
本文详细介绍了React Native新架构中如何实现一个自定义的Turbo Native Module并成功在JS进行调用,对于其中遇到的问题和细节也都有详细的介绍,欢迎大家一起交流学习。