cover_image

React Native 在「Soul App」的实践

Soul 客户端 Soul技术团队
2024年04月15日 03:32

1 背景

Soul App 采用混合技术栈的方式进行开发,除了原生和 Web 容器等技术栈外,还涉及到了 React Native 的开发。在整个开发过程中,团队遇到了不少挑战,同时也积累了丰富的实践经验。接入 React Native 是一个重要的步骤,而项目结构的调整则决定了后续开发模式和研发体验。

本文基于 React Native 0.72.10 版本,分享了在现有项目中如何引入 React Native。

2 技术选型

我们选择使用 React Native 的主要考虑因素如下:
1. 动态化更新:由于频繁的需求上线验证,需要频繁进行客户端发布。而 React Native 可以实现动态下发,不依赖客户端发布即可更新。
2. 性能:相比于原生内嵌 H5 的 Hybrid 模式,虽然 H5 也能实现动态化更新,但其性能和体验与原生相比仍有差距。而 React Native 更接近原生的性能体验。
3. 社区现状:综合考虑各方面因素后,我们认为 React Native 具有相对稳定的发展环境。目前,腾讯、携程等各大公司都有成熟的应用,并且有许多经验可供借鉴。

考虑到以上因素,我们决定使用 React Native 0.72.10 版本进行开发。这一决定主要基于以下两个原因:
1. 0.72.10 版本较新,支持 Hermes 引擎,并采用了新架构:Fabric、TurboModules。
2. iOS 平台的最低限制版本为 12.x,如果使用 RN 0.73.x 版本,则需要将最低版本调整为 13.4,这会对用户群体产生较大影响。

3 项目引入

首先,让我们来看一下官方推荐的项目结构:

project/
├── node_modules/ # 包含所有项目依赖的文件夹├── android/ # Android 平台相关文件│ ├── app/ # Android 应用的主要代码│ ├── build.gradle # Android 应用的构建配置│ └── ...├── ios/ # iOS 平台相关文件│ ├── App/ # iOS 应用的主要代码│ ├── Podfile # CocoaPods 配置文件│ └── ...├── src/ # React Native 应用源代码│ ├── components/ # 可重用的 React 组件│ ├── screens/ # 不同屏幕的 React 组件(如主屏幕、详情页等)│ ├── navigation/ # 导航相关配置(如 React Navigation)│ ├── assets/ # 图片、字体等静态资源│ ├── utils/ # 工具函数│ ├── App.js # 应用的入口文件│ └── ...├── index.js # 入口文件,初始化 React Native 应用├── package.json # 项目的元数据和依赖配置└── ...

这种项目结构需要将 iOS 和 Android 项目放在一起管理,这对于现有的开发习惯影响较大。考虑到后续对项目结构的调整不方便,因此初次确定 RN 项目结构尤为重要。以 iOS 为例,我们期望的结构如下:

project/
├── App/ # iOS 应用主工程代码├── Podfile/ # CocoaPods 配置文件├── SoulRNSDK/ # 和 React 侧通信的 Native 库└── ...
project/├── node_modules/         # 包含所有项目依赖的文件夹├── src/                  # React Native 应用源代码│   ├── components/       # 可重用的 React 组件│   ├── screens/          # 不同屏幕的 React 组件(如主屏幕、详情页等)│   ├── navigation/       # 导航相关配置(如 React Navigation)│   ├── assets/           # 图片、字体等静态资源│   ├── utils/            # 工具函数│   ├── App.js            # 应用的入口文件│   └── ...├── index.js              # 入口文件,初始化 React Native 应用├── package.json          # 项目的元数据和依赖配置└── ...

我们希望主工程下有一个私有库 SoulRNSDK,用于与 React 侧通信。不需要开发 RN 时直接依赖 SoulRNSDK 即可,而开发 RN 时,只需将库 SoulRNSDK 切换为源码,并映射 React 侧代码位置以进行调试。这种松散的结构让开发人员各司其职,并且对现有开发体验几乎没有任何影响。

但在实际操作中,我们遇到了许多问题。下面我们将针对一些典型问题,分别介绍 iOS 和 Android 两个平台的解决方案。

接入 iOS 平台

问题一:缺少文档

在引入 RN 到现有项目中时,最直接的方法是查看官方或社区提供的文档。官方确实有相应的介绍:

integration-with-existing-apps

https://reactnative.dev/docs/0.72/integration-with-existing-apps?language=objc#configuring-cocoapods-dependencies

但很遗憾,这些文档大多已经过时。例如,在官方文档中,针对库 FBReactNativeSpec 的引入是这样的:

pod 'FBReactNativeSpec', :path => "../node_modules/react-native/Libraries/FBReactNativeSpec"

但实际上新版本应该是这样的:

pod 'FBReactNativeSpec', :path => "../node_modules/react-native/React/FBReactNativeSpec"

社区文档也是基于老版本的配置方案,因此需要根据自己项目的实际情况进行调整,往往需要自己摸索。

问题二:项目脚本

在解决问题一之前,我们需要了解 RN 默认项目是如何将依赖项目导入到项目中的。经过调研,我们发现 RN 默认项目是通过 react_native_pods.rb 文件来启动依赖安装。

react_native_pods.rb 

https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb

这个文件中配置了 RN 相关基础库的依赖。因此,我们可以通过引入 react_native_pods.rb 文件,并通过调用 use_react_native 方法来安装依赖。

use_react_native

https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb#L69

需要说明的是,如果 RN 引入的三方依赖本身也依赖原生库,那么它是如何被引入到项目中的呢?实际上,这也是通过 react_native_pods.rb 文件作为处理的入口来实现的。该文件引入了 CLI 工具脚本,其中包含了 react-native-community 库中的 native_modules.rb 脚本。

native_modules.rb

https://github.com/react-native-community/cli/blob/main/packages/cli-platform-ios/native_modules.rb

其核心是 use_native_modules 方法,该方法会遍历 package.json 文件中的依赖项,然后遍历依赖项的文件夹,查找所有的 podspec 文件,并将它们存储在 packages 对象中。最后,遍历 packages,依次配置 pod

遍历 packages,依次配置 pod

https://github.com/react-native-community/cli/blob/main/packages/cli-platform-ios/native_modules.rb#L42https://github.com/react-native-community/cli/blob/main/packages/cli-platform-ios/native_modules.rb#L90

RN 的设计确实很巧妙,这也是 RN 库 Autolinking 的原理所在。然而,对于需要调整项目结构的我们来说,这也带来了新的问题:路径配置。

问题三:路径配置

use_react_native 方法是这样定义的:

def use_react_native! (  path: "../node_modules/react-native",  fabric_enabled: false,  new_arch_enabled: ENV['RCT_NEW_ARCH_ENABLED'] == '1',  production: ENV['PRODUCTION'] == '1',  hermes_enabled: ENV['USE_HERMES'] && ENV['USE_HERMES'] == '0' ? false : true,  flipper_configuration: FlipperConfiguration.disabled,  app_path: '..',  config_file_dir: '',  ios_folder: 'ios')

其中比较重要的参数有:

1. path:配置 React 项目中依赖库 react-native 的路径。
2. app_path:配置主项目的路径。
3. ios_folder:配置主项目文件夹名。

需要根据现有项目结构调整这些参数设置,但实际上发现只调整这几个参数是不够的。经常会遇到找不到 react-native-community/cli等库的情况,这是因为脚本是通过 Node 环境的 require 关键字去找对应依赖库的。当项目结构发生变化时,就会发生找不到对应库的情况。考虑到每次安装依赖时都会生成 node_modules,为了避免直接修改 node_modules,这个问题曾经困扰了我们很长时间。后来,我们想到了 Podfile 文件可以调用脚本文件。通过修改 Podfile 文件,在安装包依赖之前设置脚本,最终成功实现了动态修改文件内容的效果。

问题四:依赖繁琐

通过上述几种方法,我们初步解决了项目结构调整的问题。然而,由于 RN 相关的库配置都位于 node_modules 文件夹中,这导致每次运行项目都需要先通过 React 项目安装依赖、配置好 node_modules,才能找到相应的库配置。这对于不需要开发 RN 的团队成员来说无疑是一种困扰。同时,由于这些库都是源码形式,这也会导致编译速度变慢的问题。

针对这些挑战,我们自然而然地想到可以将 RN 相关的库事先抽取并合并成一个单独的基础库。这样,在不开发 RN 的时候,团队成员只需要依赖这个基础库即可。RN 相关的库可以分为两种:引擎本身的库和三方依赖的库。

  • 对于引擎本身的库,考虑到引擎一般很少升级,因此我们可以事先打包好这些库,需要使用时直接依赖即可。

  • 对于三方依赖的库,由于这些库可能随时升级,手动打包比较繁琐且容易出错。因此,我们制定了一个自动化链路脚本,可以自动拉取 RN 项目、安装依赖、替换脚本、打包合并并更新私有库等一系列任务。

图片

上面列举了编译脚本的流程,这一自动化流程极大地提升了更新效率,使得团队能够更快速地应对依赖库的变化。另外对可能引起疑惑的脚本步骤,这里补充两段脚本实现,方便大家理解:

  • 更新路径配置

npm install || exit 1cd '..'cp 'ios/soul_rn_script/native_modules.rb' 'source/node_modules/@react-native-community/cli-platform-ios/native_modules.rb'cp 'ios/soul_rn_script/reanimated_utils.rb' 'source/node_modules/react-native-reanimated/scripts/reanimated_utils.rb'
  • 拷贝库到基础库目录

find "$CompilePath" -type d -name "*.bundle" > bundles.txtdeclare -a bundles=()while IFS= read -r line; do    bundles+=("$line")done < bundles.txtrm bundles.txtfor bundle in "${bundles[@]}"; do    cp -r "$bundle" "$MergesPath"done

通过以上方式,我们成功实现了对开发 RN 和不开发 RN 的团队成员进行责任隔离,彼此互不影响。

接入Android平台

问题一:Gradle 兼容问题

  • Kotlin DSL:新版的 Android Studio 默认使用 Kotlin DSL 进行 Gradle 构建配置,而现有文档资料大多是针对 Groovy DSL 的,这就需要将 Groovy DSL 迁移到 Kotlin DSL,对于不熟悉 Kotlin DSL 语法和特性的开发者来说可能会遇到很多配置无效或错误的问题。

  • Gradle 版本兼容:Soul App 使用的 Gradle 插件版本是 7.3.0,Gradle 版本是 7.4.2。如果按照官方文档或者网上资料接入的话,可能会遇到 Gradle 版本兼容问题。

在解决这些问题时,我们做了相应的迁移适配。比如在 Kotlin DSL 中设置启动自动链接时,我们使用了不同的语法:

val applyNativeModules: Closure<Any> = extra.get("applyNativeModulesSettingsGradle") as Closure<Any>applyNativeModules(settings)

同时,在引用引擎插件时,我们做了相应的修改以解决 Gradle 版本兼容问题。

问题二:RN SDK 拆分问题

我们的项目结构期望是"主工程" + "RN SDK" + "RN Project",这意味着我们需要在官方文档的基础上,集成并封装出自己的 RN SDK。其功能包括了「RN 依赖的环境」「原生与 RN 的通信」「三方库依赖」「RN 所需的容器以及加载」等方面。

  • RN 库依赖问题:由于官方文档没有相关描述,而网上大部分资料都是针对旧版本(0.69 版本及以前)的内容,我们最初采取了手动集成的方式。然而,这样做可能会导致依赖库冲突、集成路径错误等问题。

  • 三方库依赖问题:此外,在集成 RN SDK 的同时,RN 项目本身可能也会用到一些三方库,比如 SVG、SafeAreaContext 等。如果原生端没有相应的依赖添加,就可能出现崩溃或无效等问题。

许多在线资料都是针对老版本的 SDK 集成,一般的步骤是将 RN 所需依赖的 RN 环境编译至 node_modules 中,然后抽取相应的 android-jsc 和 react-native 的 aar 包,并将相关的依赖同步至自己的 Library dependencies 中,最终编译成所需的 RN aar 包。

然而,我们使用的是 RN 0.72.10 版本,而官方已改变了相应的依赖方式,推荐我们使用远程依赖:

dependencies {    // Other dependencies here+   implementation "com.facebook.react:react-android"+   implementation "com.facebook.react:hermes-android"}

我们在中央仓库 Central Repository: com/facebook/react 下载了 react-android 和 hermes-android 的 aar 包,并将其上传至我们自己的 Maven 仓库。然后,再同步相关的依赖版本即可。

dependencies {    api 'cn.soul.android.lib:react-android:0.72.10-release'    api 'cn.soul.android.lib:hermes-android:0.72.10-release'
api 'com.facebook.fbjni:fbjni-java-only:0.2.2' api 'com.facebook.fresco:fresco:2.5.0' api 'com.facebook.fresco:imagepipeline-okhttp3:2.5.0' api 'com.facebook.fresco:ui-common:2.5.0' api 'com.facebook.infer.annotation:infer-annotation:0.18.0' api 'com.facebook.soloader:soloader:0.10.4' api 'com.facebook.yoga:proguard-annotations:1.19.0' api 'com.google.code.findbugs:jsr305:3.0.2' api 'com.squareup.okhttp3:okhttp-urlconnection:4.9.2' api 'com.squareup.okhttp3:okhttp:4.9.2' api 'com.squareup.okio:okio:2.9.0' api 'javax.inject:javax.inject:1'}

在项目中可能会使用到的三方库方面,我们也会做相应的封装,并将其以 aar 的方式上传至我们自己的 Maven 仓库,并在加载 RN 代码时将其 package 配置到相应位置。

dependencies {    api 'cn.soul.android.lib:androidrnsvg-release:0.0.1'    api 'cn.soul.android.lib:safeareacontext-release:0.0.1'}
mReactInstanceManager = ReactInstanceManager.builder()    .setApplication(application)    .setCurrentActivity(this)    .setJSMainModulePath("index")    .setJSBundleFile(bundlePath)    .addPackages(        SoulReactPackageList(application).getPackages().apply {            add(SoulReactPackage())            add(SvgPackage())            add(SafeAreaContextPackage())        }    )    .setInitialLifecycleState(LifecycleState.RESUMED)    .build()

问题三:RN 配置问题

  • RN 调试问题:在 Debug 调试中,我们遇到了无法动态更新已打开的 RN 页面的问题,并且一直报错如下:

com.facebook.react.common.DebugServerException:    facebook::react::Recoverable:          Could not open filehttp://xxx.ur.ip.xx:8081/index.bundle?platform=android:              No such file or directory

发现主要问题出现在 Debug 模式下,RN 所依赖的 Okhttp 库会对网络连接请求做一个拦截,在其内置的 Websocket 中,每次网络请求完毕后,会自己关闭连接。

图片

因此,我们通过复制一份源码并对其进行修改,解决了这个问题。

图片

同时附上调试步骤:
1. 连接好手机设备。
2. 打开两个命令窗口,一个执行 yarn start 启动本地服务,一个执行 adb reverse tcp:8081 tcp:8081 提供相应的端口号即可进行本地 Debug 调试。

  • RN 动态引用静态资源图片问题:在业务开发中,经常会遇到需要在 RN 代码中引用原生的静态图片资源的情况。官方文档推荐使用相对路径来引用静态图片资源,如下所示:

<Image style={{width:100,height:100}} source={require('./icon.png')}/>

我们希望在 RN 项目中有一个通用的图片资源目录,而不是与业务代码混合在一起。因此,我们尝试了使用当前项目的相对路径来引用图片资源,如下所示:

<Image source={require('Project/images/icon.png')} />

然而,我们遇到了图片加载失败或应用崩溃等问题。经过查阅相关资料和调试日志观察,我们发现所谓的图片资源相对路径实际上与 RN 代码的编写方式无关。实际上,它是相对于 Bundle 文件的路径。因此,为了解决这个问题,我们需要确保以下两种情况:

1. 对于使用当前文件相对路径的写法,必须确保图片资源与当前文件在同一级目录下:

<Image style={{width:100,height:100}} source={require('./icon.png')}/>

2. 对于使用当前项目路径的写法,不一定需要将图片资源放在 app 的资源目录下,但需要确保图片资源与 Bundle 文件在同一个根目录下:

<Image source={require('Project/images/icon.png')} />
  • RN 非首次进入崩溃问题:我们使用了 RN 0.72.10 版本,并默认开启了 Hermes 引擎。在调试或打包后,可能会遇到以下问题:首次进入 RN 页面没有问题,但返回后再次进入 RN 页面时应用崩溃,并出现以下错误信息:

java.lang.NoClassDefFoundError: com.facebook.react.jscexecutor.JSCExecutor
  • 调试和参考一些线上资料,我们发现这是因为在加载 RN Bundle 时,我们忘记配置 JavaScriptExecutor 导致的。解决方法是补上配置,如下所示:

图片

  • RN libc++_shared.so 库冲突问题:在打包 Release 版本 APK 后,安装应用后,打开 App 就会导致崩溃。错误信息如下:

java.lang.UnsatisfiedLinkError:couldn't find DSO to load: libhermes.so caused by: dlopen failed: cannot locate symbol "__emutls_get_address" referenced by "/data/app/~~wfeWu61wFHtvQtAbL0WYkQ==/cn.soulapp.android-zYnh8M8MEQvPI0ImoM3qdQ==/lib/arm64/libfolly_runtime.so"... result: 0        at com.facebook.soloader.SoLoader.doLoadLibraryBySoName(SoLoader.java:918)        at com.facebook.soloader.SoLoader.loadLibraryBySoNameImpl(SoLoader.java:740)        at com.facebook.soloader.SoLoader.loadLibraryBySoName(SoLoader.java:654)        at com.facebook.soloader.SoLoader.loadLibrary(SoLoader.java:634)        at com.facebook.soloader.SoLoader.loadLibrary(SoLoader.java:582)        at com.facebook.hermes.reactexecutor.HermesExecutor.loadLibrary(HermesExecutor.java:26)        at com.facebook.hermes.reactexecutor.HermesExecutor.<clinit>(HermesExecutor.java:20)        at com.facebook.hermes.reactexecutor.HermesExecutorFactory.create(HermesExecutorFactory.java:39)        at com.facebook.react.ReactInstanceManager$5.run(ReactInstanceManager.java:1112)at java.lang.Thread.run(Thread.java:1012)

经过查询,我们发现多个库会动态引用 libc++_shared.so 库。使用 pickFirst 'lib/arm64-v8a/libc++_shared.so' 无法保证优先使用 node-modules 中的版本,同时其他库也难免会出现适配问题。我们最初考虑将各个库中的 NDK 版本进行升级,但后续评估发现影响较大。因此,我们采取了一个折中方案:将其他库改用静态依赖替代动态依赖,以保持 RN 动态依赖的 so 库不变。当然,如果可能的话,最优解可能仍然是修改底层源码以适配。这需要后续验证。

3 后续规划

现有项目的 RN 接入只是 RN 容器侧的一小步,后续我们将结合 Soul App 的实际情况,在 RN 的稳定性、性能以及分包动态化等方面进行进一步的深入探究。

以上即为本次分享的内容。
感谢您的阅读,如果喜欢欢迎多多关注/留言/点赞。



继续滑动看下一个
Soul技术团队
向上滑动看下一个