Soul App 采用混合技术栈的方式进行开发,除了原生和 Web 容器等技术栈外,还涉及到了 React Native 的开发。在整个开发过程中,团队遇到了不少挑战,同时也积累了丰富的实践经验。接入 React Native 是一个重要的步骤,而项目结构的调整则决定了后续开发模式和研发体验。
本文基于 React Native 0.72.10 版本,分享了在现有项目中如何引入 React Native。
我们选择使用 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,这会对用户群体产生较大影响。
首先,让我们来看一下官方推荐的项目结构:
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 两个平台的解决方案。
在引入 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 1
cd '..'
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.txt
declare -a bundles=()
while IFS= read -r line; do
bundles+=("$line")
done < bundles.txt
rm bundles.txt
for bundle in "${bundles[@]}"; do
cp -r "$bundle" "$MergesPath"
done
通过以上方式,我们成功实现了对开发 RN 和不开发 RN 的团队成员进行责任隔离,彼此互不影响。
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 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 调试问题:在 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 库不变。当然,如果可能的话,最优解可能仍然是修改底层源码以适配。这需要后续验证。
现有项目的 RN 接入只是 RN 容器侧的一小步,后续我们将结合 Soul App 的实际情况,在 RN 的稳定性、性能以及分包动态化等方面进行进一步的深入探究。
以上即为本次分享的内容。
感谢您的阅读,如果喜欢欢迎多多关注/留言/点赞。