Expo 仓库内的 Brownfield 集成测试:基于 autolinking 的 integrated 原生工程配置与实现解析
【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo
本文基于 Expo monorepo 中的 integrated 测试工程,讲解如何将原生 Android/iOS 应用直接接入 Expo 模块体系——通过 Expo autolinking 在构建时从 monorepo 的node_modules解析并链接 React Native 模块。读完本文,你将理解 integrated 模式与 isolated 模式的本质区别、settings.gradle.kts 中 autolinking 配置的每一项含义、react {}扩展如何驱动 JS 打包,以及该工程为适配 Java 17、Swift 6 所做的关键配置调整。
一、integrated 工程是什么:brownfield 测试的两种集成路径
apps/brownfield-tester 是用于测试 brownfield 集成的示例应用,其目录结构体现了 Expo 面向现有原生应用(brownfield,即"棕地")集成场景的两种典型路径:
- expo-app/— React Native / Expo 应用本身,作为 JavaScript 源码以及 brownfield 构件(artifact)构建的基座;
- integrated/(本文主角)— 原生 Android 和 iOS 应用,通过 Expo autolinking 与 monorepo直接集成。它们把构建/项目根指向
expo-app/,React Native 模块在构建时从 monorepo 的node_modules中解析和链接; - isolated/— 独立的原生应用,消费预先构建好的 brownfield 构件(Maven / xcframework)。完全自包含,构建时不依赖 monorepo,官方文档称之为推荐的发布方式。
原文档中有一条必须强调的警告:
⚠️ 该应用把构建根和项目根重定向到了
../expo-app(即本仓库中 apps/brownfield-tester/expo-app/ 目录)。这是一种不应复制的无效项目布局,需要重构。请勿在其他测试或 E2E 场景中照搬此做法。
也就是说,integrated 工程的价值在于验证"autolinking 直连 monorepo"这条开发链路本身,而生产环境中对外分发应用应走 isolated 模式(见 expo-app 的构建说明 中npx expo-brownfield build:android/build:ios命令)。
expo-app的 package.json 全部使用workspace:*依赖(expo、expo-brownfield、expo-router、expo-updates、expo-dev-menu等),这正是"模块来自 monoreponode_modules"这句话的具体体现——构建时解析到的就是仓库内 packages/ 下的各 Expo 模块包。
二、Android 侧:从 Empty Activity 到完整 Expo 宿主
2.1 初始化方式
原文档说明:Android 应用由Android Studio 2025.1.3 新建的 Empty Activity 项目起步,随后按官方 Brownfield Integration 指南逐步集成 Expo 模块,最终得到位于 integrated/android/ 的工程。
2.2 版本目录与 Gradle 属性
gradle/libs.versions.toml 锁定了基础工具链:AGP8.13.0、Kotlin2.0.21、Compose BOM2024.09.00,以及core-ktx 1.10.1等 AndroidX 库。
gradle.properties(实际路径为apps/brownfield-tester/integrated/android/gradle.properties)中除android.useAndroidX=true、hermesEnabled=true、reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64等常规项外,还有两个带详细注释的开关:
android.builtInKotlin=false:文件注释解释,AGP 9 会注册自己的kotlin扩展,与应用显式应用的org.jetbrains.kotlin.android插件冲突(Cannot add extension with name 'kotlin'),因此关闭内置 Kotlin,待 AGP 10.x 移除该逃生口后可删除;android.newDsl=false:KGP 2.0.21 会把 Android 扩展强转为旧版BaseExtension,与 AGP 9 新 DSL 不兼容,关闭新 DSL 保留旧类型,待 KGP 升到 2.2.x 后可移除。
从源码结构看,这两个属性是工程在较新 AGP/KGP 组合下维持可构建性的过渡性 workaround。
2.3 根 build.gradle.kts:插件解析与版本来源
根 build.gradle.kts 的关键点:
buildscript { dependencies { // 注意:三个 classpath 均未指定版本号 classpath("com.android.tools.build:gradle") classpath("com.facebook.react:react-native-gradle-plugin") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") } } plugins { id("expo-root-project") id("com.facebook.react.rootproject") }版本号被刻意省略,由expo-root-project插件与 React Native 的rootproject插件从 monorepo 依赖树中统一解析——这是"构建时直连 monorepo"策略的又一处落点。
2.4 settings.gradle.kts:autolinking 的核心配置
settings.gradle.kts 是整个 integrated 模式的枢纽,值得逐段拆解:
pluginManagement { // 1) 用 node 从 monorepo 依赖树解析 react-native 官方 gradle 插件位置,并 includeBuild val reactNativeGradlePlugin = File( providers.exec { workingDir(rootDir) commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })") }.standardOutput.asText.get().trim() ).parentFile.absolutePath includeBuild(reactNativeGradlePlugin) // 2) 同理解析 expo-modules-autolinking 自带的 expo-gradle-plugin 并 includeBuild val expoPluginsPath = File( providers.exec { /* 解析 expo-modules-autolinking/package.json */ }, "../android/expo-gradle-plugin" ).absolutePath includeBuild(expoPluginsPath) // ... repositories } plugins { id("com.facebook.react.settings") id("expo-autolinking-settings") } // 3) 核心:把 autolinking 的项目根重定向到 ../expo-app(即仓库中的 expo-app 目录) expoAutolinking { projectRoot = File(rootDir, "../../expo-app") } // 4) 用 expo 提供的命令生成 RN 原生库链接清单,并以 monorepo 根目录的 pnpm-lock.yaml 作为输入 extensions.configure<com.facebook.react.ReactSettingsExtension> { autolinkLibrariesFromCommand( expoAutolinking.rnConfigCommand, rootDir, files("../../../../pnpm-lock.yaml") ) } expoAutolinking.useExpoModules() rootProject.name = "Brownfield" expoAutolinking.useExpoVersionCatalog() includeBuild(expoAutolinking.reactNativeGradlePlugin) include(":app")各段含义:
- 两个
includeBuild:分别把@react-native/gradle-plugin与 packages/expo-modules-autolinking 内的expo-gradle-plugin作为复合构建引入。用node --print require.resolve(...)解析路径,保证拿到的一定是 monoreponode_modules里实际安装的那份,而不是猜测路径。 expoAutolinking { projectRoot = File(rootDir, "../../expo-app") }:即原文档警告中"把构建根重定向到../expo-app"的具体实现。后续所有依赖解析(react-native、expo等)都以该目录为出发点。autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand, ...):不手写dependencies { implementation(project(":react-native-xxx")) },而是运行 Expo 的 autolinking 命令动态生成原生库清单;files("../../../../pnpm-lock.yaml")指向 monorepo 根目录的锁文件(integrated/android向上四级即仓库根),使依赖解析结果与 workspace 安装完全一致。useExpoModules()与useExpoVersionCatalog():前者把 Expo 模块纳入自动链接范围,后者让原生依赖版本由 Expo 版本目录统一管理,避免与 monorepo 版本漂移。
2.5 app/build.gradle.kts:React Native 集成与 JS 打包
应用模块 app/build.gradle.kts 在标准 Empty Activity 模板(compileSdk = 37、minSdk = 24、targetSdk = 36、Compose)基础上,追加了 React Native 相关配置:
dependencies { // Compose 相关依赖…… implementation("com.facebook.react:react-android") implementation("com.facebook.react:hermes-android") } val projectRoot = File(rootDir.absoluteFile, "../../expo-app").absolutePath react { root = File(projectRoot) // 通过 expo/scripts/resolveAppEntry 动态解析 JS 入口(支持 Expo 的入口约定) entryFile = file(listOf("node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute").let { ... }) // 以下路径全部用 node require.resolve 从 monorepo 依赖树动态解析 reactNativeDir = file(/* require.resolve('react-native/package.json') */) hermesCommand = file(/* require.resolve('hermes-compiler/...') + "/hermesc/%OS-BIN%/hermesc" */) codegenDir = file(/* require.resolve('@react-native/codegen/...') */) enableBundleCompression = false // 用 Expo CLI 执行打包,确保 Metro 配置在 Expo 项目下正确工作 cliFile = file(/* require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] }) */) bundleCommand = "export:embed" autolinkLibrariesWithApp() }几个要点:
cliFile+bundleCommand = "export:embed":JS 打包不交给 RN 原生 Gradle 插件默认逻辑,而是委托给 monorepo 中解析出的@expo/cli执行export:embed,注释明确说明这是为了保证 Metro 配置在 Expo 项目中正确生效;- 动态路径解析贯穿始终:
entryFile、reactNativeDir、hermesCommand、codegenDir、cliFile全部通过node --print require.resolve(...)获取,与 settings 阶段的思路一脉相承——整个工程不硬编码任何 node_modules 绝对路径; - 注意该文件中没有
compileOptions块:这正是原文档"最后一步"调整后的结果(见 2.6)。
2.6 原文档所述的两处"最后一步"调整
原文档指出,由于React Native 目标为 Java 17,初始化后做了两处修改:
- 从
app/build.gradle.kts移除默认的compileOptions(Empty Activity 模板会写sourceCompatibility/targetCompatibility = VERSION_1_8,会强制字节码级别与 RN 要求的 17 冲突)。对照当前 app/build.gradle.kts 全文,确实不存在任何compileOptions配置,说明移除后未再手工设置 Java 版本,交由工具链默认(Java 17)处理; - 从
settings.gradle.kts的dependencyResolutionManagement中移除repositoriesMode,原因是react-native插件自身会配置 Maven 仓库,保留强制模式会导致冲突。当前 settings.gradle.kts 全文中没有dependencyResolutionManagement块,与文档描述一致。
三、Android 侧运行时:原生壳如何拉起 Expo
三个 Kotlin 源文件完整呈现了 brownfield 宿主的形态:
- MyApplication.kt:实现
ReactApplication接口,onCreate()中依次调用loadReactNative(this)与 Expo 的ApplicationLifecycleDispatcher.onApplicationCreate(this);reactHost由ExpoReactHostFactory.getDefaultReactHost(...)创建并传入PackageList(autolink 的原生包清单,未链接的包可手动add())。这是 Expo 模块体系接管 ReactHost 生命周期的关键入口。 - ExpoActivity.kt:主组件名固定为
"main"(对应expo-app的 expo-router 入口组件);createReactActivityDelegate()返回ReactActivityDelegateWrapper包裹的DefaultReactActivityDelegate,并以BuildConfig.IS_NEW_ARCHITECTURE_ENABLED与fabricEnabled控制新架构开关——这正是 Brownfield 集成指南中 RN 屏幕的标准写法。 - MainActivity.kt:一个纯 Compose 原生界面(Empty Activity 模板产物),展示 "Welcome to Brownfield Tester" 文案与一个 "Open Expo Screen" 按钮,点击后
startActivity(Intent(context, ExpoActivity::class.java))跳转到 RN 界面——直观演示了 brownfield 的核心场景:从既有原生屏幕按需进入 React Native 屏幕。
AndroidManifest.xml 注册了两个 Activity:MainActivity(LAUNCHER 入口)与ExpoActivity(Theme.AppCompat.Light.NoActionBar,无 intent-filter,仅由代码拉起);debug 变体的 manifest 追加SYSTEM_ALERT_WINDOW权限并允许 cleartext 流量,为调试期开发菜单/远程调试服务。
四、iOS 侧:SwiftUI 项目与 Swift 6 适配
原文档说明:iOS 应用由Xcode 26 新建的 SwiftUI 项目起步,同样按 Brownfield Integration 指南集成 Expo 模块。最后的适配步骤是:由于 Swift 6 尚未被完全支持,需要在项目设置中将 "Default Actor isolation" 设为 "nonisolated"。
这一改动在 project.pbxproj 中得到印证,Debug 与 Release 两个 build configuration 均包含:
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_VERSION = 5.0;即把默认 actor 隔离回退为非隔离模式、并保持 Swift 5.0 语言模式,规避 Expo 相关 Swift 源码在 Swift 6 严格并发检查下的编译问题。
五、integrated 工程在 brownfield 测试体系中的位置
把 integrated 工程、expo-app 与 isolated 模式串起来看,可以得到 brownfield 测试的完整图景:
- integrated 模式(本文):原生壳 + monorepo 直连 autolinking,验证
expo-brownfield与各 Expo 模块在"构建时解析"路径下的正确性,适合 monorepo 内部持续集成;debug 下配合 Metro(在expo-app目录yarn start,仅 Android 的 Debug/All 构建类型支持热更 JS)。 - isolated 模式:通过
expo-brownfield内置 CLI 生成独立构件——npx expo-brownfield build:android --repo MavenLocal --all --verbose与npx expo-brownfield build:ios --release --verbose——产物走 Maven / xcframework 被独立原生应用消费,是推荐的分发方式。 expo-app自身还承担expo-brownfield的 E2E 测试基座角色。
六、关键结论与使用注意
- integrated 模式的本质是把 autolinking 的
projectRoot、依赖解析、JS 打包入口全部重定向到 monorepo 内的expo-app,构建时"所见即所得"地链接 workspace 中的 Expo 模块;其代价是原生工程与 monorepo 深度耦合。 - 原文档明确警示:该重定向属于无效项目布局,仅作测试用途,不要在正式测试或 E2E 工程中复制;对外分发请使用 isolated(Maven / xcframework)方案。
- 两处版本适配细节(Android 移除
compileOptions/repositoriesMode以匹配 Java 17 与 RN 插件的仓库配置;iOS 将默认 actor 隔离设为nonisolated)是跟随 RN/Expo 工具链新特性演进时最典型的"最后一步"改动,可作为 brownfield 集成排障的参考点。
【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考