React Native新架构系列-C++实现跨平台Turbo Native Module

今天我们介绍在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写法的一些差异。

1.什么场景下需要C++ Turbo Native Module

一般遇到如下场景,我们考虑使用C++ Turbo Native Module来扩展我们的API:

  • 具体处理逻辑不依赖特定平台(比如网络请求,图片处理、视频处理等)
  • 已经有C++实现代码可以直接复用(例如将一些C++库扩展到React Native JS侧进行使用)
  • 性能要求高,需要C++实现

2.如何开发一个C++ Turbo Native Module

下面我们以一个具体的例子,详细介绍如何实现一个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.1.定义API

这里我们定义实现2个API用于以同步的方式计算一段字符串的base64编码,具体实现使用C++实现,API定义如下:

1.API名称:

  • base64EncodeSync

2.参数定义:

参数名称 参数类型 参数说明
str string 需要base64编码的字符串内容

3.返回值

返回值名称 返回值类型 返回值说明
/ string base64编码的结果

2.2.创建文件夹

我们在上面的工程根目录中创建一个tm目录用来放置我们的JS和C++代码,目录结构如下:

ReactNativeSample
├── __tests__
├── android
├── ios
├── node_modules
├── src
└── tm

与之前不同,因为我们只有C++代码和JS代码,所以我们直接放置在tm目录下即可。

2.3.使用TypeScript定义API接口代码

接下来我们首先根据我们之前的API定义来定义JS侧接口代码,这部分代码是最终需要我们在React Native业务代码中使用的对外接口代码,放在tm目录下。

新架构要求我们必须使用TypeScript或者Flow进行定义,这是为了接口定义中出入参类型是明确的,方便后面Codegen依据此进行模版代码生成。同时还有2个要求。

  1. 该文件必须命名为Native ,使用 Flow 时扩展名为.js.jsx ,使用 TypeScript 时扩展名为.ts.tsx 。 Codegen 将仅查找与此模式匹配的文件。
  2. 该文件必须导出TurboModuleRegistrySpec对象。

我们这里根据我们前面的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");

需要注意以下几点:

  1. 模块定义必须是一个名称为Spec的interface
  2. 必须继承自TurboModule
  3. TurboModuleRegistry中get需要填写的模块名为我们之前定义的模块名(但需要注意不是文件名,不包含Native前缀),这行代码执行的时候,会加载我们的模块

2.4.Codegen配置

2.4.1.修改package.json

我们修改工程中的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配置需要说明:

  • name与最终生成的C++模板代码的名称有关,这里一般为模块名称添加Spec后缀
  • jsSrcsDir是codegen寻找模块定义的API代码时查找的目录名称,我们这里是tm目录
  • android.javaPackageName是定义最终生成的Android模板代码的包名称【本次我们不需要Android模板代码】

这里需要注意,我们上一节讲的Turbo Native Module是使用kotlin实现的,使用时只要我们安装了相关的npm包,就会自动生成和关联相关代码,这被称为autolink。但C++代码不会,所以我们需要下面的步骤来处理C++代码的编译配置。这部分涉及到一些Android NDK开发的知识。

2.4.2.添加CMakeLists.txt

因为我们需要用到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.jsoncodegenConfig的name字段的定义有关。

2.4.3.给Android工程添加NDK支持

因为我们新创建的React Native工程缺少C++相关的配置,所以我们需要给我们的android工程下的主module(app module)添加NDK支持。

我们创建目录android/app/src/main/jni,然后拷贝default-app-setup这个网址中的CMakeLists.txtOnLoad.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;
    });
}

这个文件中主要的变动有如下几点:

  1. 添加了头文件引入#include ,这个头文件及其对应的cpp文件是React Native CLI自动生成的。它可以看作是codegen的其中一环,负责将我们实现的自定义模块autolink起来(可以回忆上一节的内容,我们只需要安装npm包即可将我们自定义的API接入React Native工程中,其实背后是生成了一些C++代码,但是都被自动link到了工程中)。

  2. JNI_OnLoad函数会在so文件被加载时调用,这里给React Native的C++代码初始化注入了3个函数。registerComponents和javaModuleProvider目前我们都使用rncli.h中提供的方法即可。

  3. 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"
       }
   }
}

2.4.4.运行Codegen生成模板代码

经过上面的配置,我们就可以在项目根目录下执行如下命令来触发codegen的运行:

yarn android

最终生成的模板代码在ReactNativeSample/android/app/build/generated/source/codegen/jni目录下。

2.5.编写具体API实现C++代码

经过上面的步骤,项目的配置,模板代码都已经生成好了,接下来我们就开始编写具体的C++代码了。

我们需要在tm目录下创建RTNBase64HelperModule.hRTNBase64HelperModule.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++的类型进行转换。

2.6.注册自定义模块&添加编译配置

在实现完代码后,我们需要把我们的模块注册到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文件中。

3.如何使用自定义C++ Turbo Native Module

3.1.编译

因为本文我们并没有将C++代码放在单独的npm包中分发。你应该可以从上面的步骤中看出,C++代码单独分发相比于上一节的Android实现代码分发要复杂一些,主要是目前还不支持autolink,需要手动修改React Native工程中的一些配置。

其实也不是完全不可能分发,我们可以尝试将codegen生成的代码也一并放在npm包中分发,这样工程中只需要注册模块和添加我们的子目录作为编译配置的一环即可,这里暂不做尝试,期待官方后续更友好的集成方式。

目前我们的代码都在ReactNativeSample这个项目中,所以我们直接使用下面的命令即可编译安装:

yarn android

3.2.JS调用

由于我们之前已经写了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,对于其中遇到的问题和细节也都有详细的介绍,欢迎大家一起交流学习。

你可能感兴趣的:(React,Native,react,native,c++,react.js)