今天我们介绍在React Native新架构中如何C++代码实现一个跨平台的C++ Turbo Native Module扩展API。阅读本文前建议阅读上一篇文章。本系列基于React Native 0.73.4版本,从一名Android开发者的视角进行介绍。本系列介绍的内容默认读者对React Native有一定的了解,对基础的开发内容不再赘述。
在上一篇文章中React Native新架构系列-自定义Turbo Native Module扩展API,我们通过在Android侧写实现代码,实现了2个测量文本宽度的API,文中也提到,实际场景中,我们还需要在iOS侧实现一份同样的逻辑。这是因为我们的API其实是跟平台(Android/iOS/HarmonyOS)相关的。但如果一个API的具体实现与平台无关,那我们就可以通过C++来只写一份代码,在多个平台复用,这就是本文介绍的内容。本文的主要内容参考自React Native开源代码中关于Cross-Platform Turbo Native Modules with C++的Android和JS部分的介绍,并结合一个实际的例子,会在其中穿插一下自己遇到的问题和理解,同时也会简要对比一下跟上一节中提到的Turbo Native Module写法的一些差异。
一般遇到如下场景,我们考虑使用C++ Turbo Native Module来扩展我们的API:
下面我们以一个具体的例子,详细介绍如何实现一个C++ Turbo Native Module来自定义API提供给JS侧使用,同时我们会给出一些与上一节介绍的Turbo Native Module的实现差异。
需要注意的是,在官方文档Cross-Platform Turbo Native Modules with C++中,是直接新建了一个React Native工程,并在其中添加了C++代码,这种方式与Android的NDK开发类似。我们这里直接使用上一节创建的ReactNativeSample工程进行演示,可以直接clone下面的代码开始。
ReactNativeSample:https://github.com/linkecoding/ReactNativeSample
这里我们定义实现2个API用于以同步的方式计算一段字符串的base64编码,具体实现使用C++实现,API定义如下:
1.API名称:
2.参数定义:
参数名称 | 参数类型 | 参数说明 |
---|---|---|
str | string | 需要base64编码的字符串内容 |
3.返回值
返回值名称 | 返回值类型 | 返回值说明 |
---|---|---|
/ | string | base64编码的结果 |
我们在上面的工程根目录中创建一个tm
目录用来放置我们的JS和C++代码,目录结构如下:
ReactNativeSample
├── __tests__
├── android
├── ios
├── node_modules
├── src
└── tm
与之前不同,因为我们只有C++代码和JS代码,所以我们直接放置在tm目录下即可。
接下来我们首先根据我们之前的API定义来定义JS侧接口代码,这部分代码是最终需要我们在React Native业务代码中使用的对外接口代码,放在tm目录下。
新架构要求我们必须使用TypeScript或者Flow进行定义,这是为了接口定义中出入参类型是明确的,方便后面Codegen依据此进行模版代码生成。同时还有2个要求。
Native
,使用 Flow 时扩展名为.js
或.jsx
,使用 TypeScript 时扩展名为.ts
或.tsx
。 Codegen 将仅查找与此模式匹配的文件。我们这里根据我们前面的API定义进行相关代码编写如下,具体可以查看代码中的注释:
// 文件名:NativeRTNBase64Helper.ts
import { TurboModule, TurboModuleRegistry } from "react-native";
export interface Spec extends TurboModule {
readonly base64EncodeSync: (str: string) => string;
}
export default TurboModuleRegistry.getEnforcing<Spec>("RTNBase64Helper");
需要注意以下几点:
Native
前缀),这行代码执行的时候,会加载我们的模块我们修改工程中的package.json
文件添加codegen相关配置,内容如下:
{
"name": "ReactNativeSample",
"version": "0.0.1",
"private": true,
"scripts": {
"android": "react-native run-android",
"ios": "react-native run-ios",
"lint": "eslint .",
"start": "react-native start",
"test": "jest"
},
"dependencies": {
"react": "18.2.0",
"react-native": "0.73.4"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0",
"@react-native/babel-preset": "0.73.21",
"@react-native/eslint-config": "0.73.2",
"@react-native/metro-config": "0.73.5",
"@react-native/typescript-config": "0.73.1",
"@types/react": "^18.2.6",
"@types/react-test-renderer": "^18.0.0",
"babel-jest": "^29.6.3",
"eslint": "^8.19.0",
"jest": "^29.6.3",
"prettier": "2.8.8",
"react-test-renderer": "18.2.0",
"typescript": "5.0.4"
},
"engines": {
"node": ">=18"
},
"codegenConfig": {
"name": "RTNBase64HelperSpec",
"type": "modules",
"jsSrcsDir": "tm",
"android": {
"javaPackageName": "com.sample.rtnbase64helper"
}
}
}
codegenConfig配置需要说明:
这里需要注意,我们上一节讲的Turbo Native Module是使用kotlin实现的,使用时只要我们安装了相关的npm包,就会自动生成和关联相关代码,这被称为autolink
。但C++代码不会,所以我们需要下面的步骤来处理C++代码的编译配置。这部分涉及到一些Android NDK开发的知识。
因为我们需要用到C++代码,所以我们需要在tm目录下创建一个CMakeLists.txt
文件用于管理C++文件相关的编译。具体内容如下,我在关键位置添加了注释:
cmake_minimum_required(VERSION 3.13)
set(CMAKE_VERBOSE_MAKEFILE on)
add_compile_options(
-fexceptions
-frtti
-std=c++17)
# 将当前目录下的cpp结尾的文件赋值给变量tm_SRC
file(GLOB tm_SRC CONFIGURE_DEPENDS *.cpp)
# 创建名为tm的静态链接库
add_library(tm STATIC ${tm_SRC})
# 将当前目录添加到头文件查找目录
target_include_directories(tm PUBLIC .)
# 将当前目录添加到头文件查找目录
target_include_directories(react_codegen_RTNBase64HelperSpec PUBLIC .)
# 为tm这个库设置需要链接的库
target_link_libraries(tm
jsi
react_nativemodule_core
react_codegen_RTNBase64HelperSpec)
这里需要注意的是react_codegen_RTNBase64HelperSpec
这个名称,这与我们上面在package.json
的codegenConfig
的name字段的定义有关。
因为我们新创建的React Native工程缺少C++相关的配置,所以我们需要给我们的android工程下的主module(app module)添加NDK支持。
我们创建目录android/app/src/main/jni
,然后拷贝default-app-setup这个网址中的CMakeLists.txt
和OnLoad.cpp
文件到jni目录下。
1.添加OnLoad.cpp文件
OnLoad.cpp
文件的内容我删掉了一些注释和无关代码,可以修改如下:
// OnLoad.cpp文件
#include
#include
#include
#include
#include
namespace facebook::react {
void registerComponents(
std::shared_ptr registry) {
rncli_registerProviders(registry);
}
std::shared_ptr cxxModuleProvider(
const std::string &name,
const std::shared_ptr &jsInvoker) {
return nullptr;
}
std::shared_ptr javaModuleProvider(
const std::string &name,
const JavaTurboModule::InitParams ¶ms) {
return rncli_ModuleProvider(name, params);
}
} // namespace facebook::react
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
return facebook::jni::initialize(vm, [] {
facebook::react::DefaultTurboModuleManagerDelegate::cxxModuleProvider =
&facebook::react::cxxModuleProvider;
facebook::react::DefaultTurboModuleManagerDelegate::javaModuleProvider =
&facebook::react::javaModuleProvider;
facebook::react::DefaultComponentsRegistry::
registerComponentDescriptorsFromEntryPoint =
&facebook::react::registerComponents;
});
}
这个文件中主要的变动有如下几点:
添加了头文件引入#include
,这个头文件及其对应的cpp文件是React Native CLI自动生成的。它可以看作是codegen的其中一环,负责将我们实现的自定义模块autolink起来(可以回忆上一节的内容,我们只需要安装npm包即可将我们自定义的API接入React Native工程中,其实背后是生成了一些C++代码,但是都被自动link到了工程中)。
JNI_OnLoad
函数会在so文件被加载时调用,这里给React Native的C++代码初始化注入了3个函数。registerComponents和javaModuleProvider目前我们都使用rncli.h
中提供的方法即可。
cxxModuleProvider
负责注册我们本节实现的C++ API,根据模块名称来进行区分,我们会在后面把它的细节补充完整。
2.添加CMakeLists.txt文件并指定
这我们只需要拷贝上面指定的CMakeLists.txt文件即可,内容如下:
# CMakeLists.txt文件
cmake_minimum_required(VERSION 3.13)
# Define the library name here.
project(appmodules)
# This file includes all the necessary to let you build your application with the New Architecture.
include(${REACT_ANDROID_DIR}/cmake-utils/ReactNative-application.cmake)
# App needs to add and link against tm (TurboModules) folder
这里文中最主要的include命令引用了ReactNativeSample/node_modules/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake
这个路径的文件,内部已经帮我们配置好了大部分的配置,我们只需要在后面添加我们自己的C++配置即可。
然后我们将CMakeLists.txt文件的路径指定给gradle便于其进行构建。我们需要在android/app/build.gradle
文件的android块中添加如下内容:
android {
externalNativeBuild {
cmake {
path "src/main/jni/CMakeLists.txt"
}
}
}
经过上面的配置,我们就可以在项目根目录下执行如下命令来触发codegen的运行:
yarn android
最终生成的模板代码在ReactNativeSample/android/app/build/generated/source/codegen/jni
目录下。
经过上面的步骤,项目的配置,模板代码都已经生成好了,接下来我们就开始编写具体的C++代码了。
我们需要在tm目录下创建RTNBase64HelperModule.h
和RTNBase64HelperModule.cpp
文件。
它们的具体实现如下:
// RTNBase64HelperModule.h
#pragma once
#if __has_include() // CocoaPod headers on Apple
#include
#elif __has_include("RTNBase64HelperSpecJSI.h") // CMake headers on Android
#include "RTNBase64HelperSpecJSI.h"
#endif
#include
#include
namespace facebook::react {
class RTNBase64HelperModule : public NativeRTNBase64HelperCxxSpec {
public:
RTNBase64HelperModule(std::shared_ptr jsInvoker);
jsi::String base64EncodeSync(jsi::Runtime &rt, jsi::String str);
std::string base64_encode(const std::string &input);
};
} // namespace facebook::react
对于Android平台来说,我们需要引入codegen帮我们生成的头文件RTNBase64HelperSpecJSI.h
,然后我们的模块需要继承自NativeRTNBase64HelperCxxSpec
(这个是codegen帮我们生成的类)
然后我们在C++代码中完成具体的逻辑实现:
// RTNBase64HelperModule.cpp
#include
#include
#include "RTNBase64HelperModule.h"
namespace facebook::react {
RTNBase64HelperModule::RTNBase64HelperModule(std::shared_ptr jsInvoker)
: NativeRTNBase64HelperCxxSpec(std::move(jsInvoker)) {}
jsi::String RTNBase64HelperModule::base64EncodeSync(jsi::Runtime &rt, jsi::String str) {
auto data = str.utf8(rt);
auto result = base64_encode(data);
return jsi::String::createFromUtf8(rt, result);
}
std::string RTNBase64HelperModule::base64_encode(const std::string &input) {
static const std::string base64_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789+/";
std::string ret;
int val = 0, valb = -6;
for (unsigned char c: input) {
val = (val << 8) + c;
valb += 8;
while (valb >= 0) {
ret.push_back(base64_chars[(val >> valb) & 0x3F]);
valb -= 6;
}
}
if (valb > -6) {
ret.push_back('=');
if (valb > -4) ret.push_back('=');
}
return ret;
}
} // namespace facebook::react
这里实现了一个简单版本的base64编码作为演示。
需要注意的是,这里传入的数据是jsi
定义的类型,它内部提供了很多方法让我们可以将其与C++的类型进行转换。
在实现完代码后,我们需要把我们的模块注册到React Native项目中,同时还需要把我们的C++代码编译产物加入最终app的编译配置。
1.注册模块
我们打开之前的OnLoad.cpp
文件,修改cxxModuleProvider
方法的实现如下:
std::shared_ptr cxxModuleProvider(
const std::string &name,
const std::shared_ptr &jsInvoker) {
if (name == "RTNBase64Helper") {
return std::make_shared(jsInvoker);
}
return nullptr;
}
我们根据模块名来返回我们自己的模块,注意这里的名字与我们之前定义的名字和后续在JS侧调用的名字要保持一致。
2.添加编译配置
这一步我们要将我们自己的C++文件编译产物与app中编译的so链接到一起。我们需要修改android/app/src/main/jni/CMakeLists.txt
的内容如下:
cmake_minimum_required(VERSION 3.13)
# Define the library name here.
project(appmodules)
# This file includes all the necessary to let you build your application with the New Architecture.
include(${REACT_ANDROID_DIR}/cmake-utils/ReactNative-application.cmake)
# App needs to add and link against tm (TurboModules) folder
add_subdirectory(${REACT_ANDROID_DIR}/../../../tm/ tm_build)
target_link_libraries(${CMAKE_PROJECT_NAME} tm)
注意,本文涉及到2个CMakeLists.txt。
tm/CMakeLists.txt
这个文件是定义了将我们自己的C++文件编译为so文件android/app/src/main/jni/CMakeLists.txt
这个文件定义了app的编译过程会触发上面tm目录的编译,同时会将编译产物link到app的so文件中。因为本文我们并没有将C++代码放在单独的npm包中分发。你应该可以从上面的步骤中看出,C++代码单独分发相比于上一节的Android实现代码分发要复杂一些,主要是目前还不支持autolink,需要手动修改React Native工程中的一些配置。
其实也不是完全不可能分发,我们可以尝试将codegen生成的代码也一并放在npm包中分发,这样工程中只需要注册模块和添加我们的子目录作为编译配置的一环即可,这里暂不做尝试,期待官方后续更友好的集成方式。
目前我们的代码都在ReactNativeSample这个项目中,所以我们直接使用下面的命令即可编译安装:
yarn android
由于我们之前已经写了JS侧的代码,我们可以直接调用我们之前的JS代码,最终会执行到我们的C++实现,这里放一个示例代码:
import React, { useEffect } from 'react';
import { SafeAreaView, ScrollView, StatusBar, Text } from 'react-native';
import RTNBase64Helper from './tm/NativeRTNBase64Helper';
function App(): React.JSX.Element {
useEffect(() => {
const result = RTNBase64Helper.base64EncodeSync(
'abcdennkkdjededkedjkdjekj',
);
console.log('===base64 result===', result);
}, []);
return (
Sample
);
}
export default App;
然后我们执行下面的命令即可:
# 首先连接好设备
cd ReactNativeSample
yarn android
最后就可以在控制台看到输出日志如下
LOG ===base64 result=== YWJjZGVubmtrZGp1ZGVka2VkamtkamVra=
本文详细介绍了React Native新架构中如何实现一个自定义的C++ Turbo Native Module并成功在JS进行调用,方便我们将C++实现的跨平台代码直接对接到React Native的JS,对于其中遇到的问题和细节也都有详细的介绍,欢迎大家一起交流学习。