当一个 React Native 应用既要跑在手机上,又要跑在专用设备上,工程问题很快就会变得比"做两个页面"复杂得多。
入口文件要不要分开?应用壳要不要分开?Metro 能不能只打当前端需要的代码?Android 能不能稳定产出两个不同身份、不同职责的 APK?
这篇文章不讲某个具体业务,而是基于一套真实存在的 RN 双端工程实现 ,抽象出一条比较完整、也比较实用的落地链路:同一个仓库里,如何同时支持手机端和设备端的分端运行与分端打包。
1. 为什么 RN 项目会出现"分端打包"需求?
很多人第一次遇到这类需求时,直觉是:既然两个端长得不一样,那就拆成两个仓库。
但真实工程里,问题通常没这么简单。
一类典型场景是:
- 一端运行在手机上,承担登录、配置、管理、查看状态等轻交互能力
- 另一端运行在设备上,承担执行、采集、长驻、系统级能力接入等更重的职责
这两类端往往既不同,又没那么不同。
不同的是:
- 启动入口不同
- 顶层导航和应用壳不同
- 是否需要登录、是否显示 Tab、是否强制竖屏,可能都不同
- 原生层能力也可能不同,比如系统权限、自启动、设备角色、清单配置
相同的是:
- 底层 React Native 工程是一套
- 状态管理、工具层、网络层、公共组件经常能大量复用
- 很多能力只是"接入方式不同",不是"完全不同的产品"
从维护成本看,把它们放在一个仓库里通常更划算:
- 公共代码只维护一份
- RN 版本升级只做一次
- 原生依赖只维护一套基础工程
- 组件、Hook、工具层不会因为复制仓库而逐渐分叉
所以,这类工程真正要解决的不是"如何打两个包",而是:
如何在一个 RN 仓库里,同时表达两种运行形态,又不让两端彼此污染。
这件事落到工程上,最终会拆成两层问题:
- 运行时怎么分
- 构建时怎么分
2. 分端打包不是只换一个 APK,而是拆成两层问题
如果只把"分端打包"理解成"最后产出两个 APK",通常会很快踩坑。
因为在 RN 里,真正需要分的,远不止产物本身。
先说结论
一套可落地的双端方案,至少要同时处理这两条线:
运行期分端
解决的问题是:
- 当前到底是手机端还是设备端
- 当前应该走哪个 JS 入口
- 当前应该挂哪套应用壳
- 当前应该打开哪些运行期开关
- 当前 Metro 该不该把另一端代码也带进来
构建期分端
解决的问题是:
- Android 应该跑哪个 Gradle task
- 选哪个 flavor
- 合并哪份 Manifest
- 用哪个包名后缀
- 最终把 APK 输出到哪里
这两件事看起来相关,但职责完全不同。
一张图看清职责分层
构建期分端
选择 Gradle task
选择 productFlavor
合并不同 Manifest
生成不同 applicationId / 版本后缀
输出不同 APK 目录
运行期分端
切换 .env
注入 APP_TYPE 等运行参数
选择 JS 入口
挂载对应应用壳
Metro 只保留当前端所需代码
如果只做运行期分端,不做构建期分端,通常会出现:
- 两端虽然能切入口,但 Android 安装包还是一个
- 包名、权限、系统角色无法真正分开
如果只做构建期分端,不做运行期分端,通常会出现:
- 构建任务不同,但 JS 世界仍然混在一起
- 应用壳、页面范围、入口文件无法稳定切换
所以更稳妥的思路是:
把"当前跑什么"交给运行期,把"最终产什么"交给构建期。
3. 一套常见的 RN 双端工程骨架,通常长什么样?
先看一眼这类工程在目录上通常会长成什么样。下面这组文件来自真实代码落地后的抽象表达:
text
.
├── package.json
├── .env
├── .env.mobile
├── .env.robot
├── index.js
├── index.mobile.js
├── index.robot.js
├── metro.config.js
├── scripts/
│ ├── load-env.js
│ ├── inject-globals.js
│ ├── code-separator.js
│ └── build.js
├── src/
│ ├── App.tsx
│ ├── RobotApp.tsx
│ └── config/environment.ts
├── android/
│ └── app/
│ ├── build.gradle
│ └── src/
│ ├── main/AndroidManifest.xml
│ ├── mobile/AndroidManifest.xml
│ └── robot/AndroidManifest.xml
真正让双端方案落地的,往往不是某一个配置文件,而是一整条串起来的链路。
这条链路里,每个文件分别承担一种职责:
| 文件 | 解决的问题 |
|---|---|
package.json |
对外暴露按端运行、按端构建的命令 |
.env.mobile / .env.robot |
定义当前模式的最小环境开关 |
scripts/load-env.js |
读取 .env,把模式信息变成脚本可用数据 |
scripts/inject-globals.js |
把环境信息注入 JS 运行时 |
src/config/environment.ts |
给上层代码提供统一的运行期判断接口 |
index.mobile.js / index.robot.js |
把当前端映射到不同入口 |
src/App.tsx / src/RobotApp.tsx |
承担两套不同的应用壳 |
scripts/code-separator.js |
告诉 Metro 当前应该排除哪些代码 |
metro.config.js |
组合 blockList、alias、注入脚本和打包行为 |
android/app/build.gradle |
把 mobile / robot 变成真实 flavor |
android/app/src/mobile / android/app/src/robot |
把 Android 原生差异落到 Manifest 和源集 |
从工程角度看,这套骨架有两个非常重要的特征:
- JS 层先把运行形态分开
- Android 原生层再把安装包真正分开
4. 运行期分端:先决定"当前到底是哪个端"
这一层解决的是一个很基础,但也最容易被低估的问题:
当开发者执行
npm run start:m或npm run start:r时,JS 世界是怎么知道自己现在到底处于哪种端模式的?
4.1 入口命令其实很简单,但非常关键
真实代码里的关键脚本大致是这样:
json
{
"scripts": {
"start:m": "cp .env.mobile .env && react-native start",
"start:r": "cp .env.robot .env && react-native start",
"android:m": "cp .env.mobile .env && react-native run-android --tasks installMobileDebug --appIdSuffix mobile",
"android:r": "cp .env.robot .env && react-native run-android --tasks installRobotDebug --appIdSuffix robot",
"build:m": "cp .env.mobile .env && cd android && ./gradlew assembleMobileRelease",
"build:r": "cp .env.robot .env && cd android && ./gradlew assembleRobotRelease"
}
}
这一步没有引入复杂工具,做法非常直接:
- 先把
.env.mobile或.env.robot复制成.env - 再启动 Metro 或 Gradle
它看起来朴素,但其实给后面的所有环节定下了基调:
- 当前模式是谁
- Metro 该怎么排代码
- 入口文件该往哪边走
- Android 构建命令该选择哪条分支
4.2 .env.mobile / .env.robot 承担的,不是"完整配置",而是"模式开关"
真实文件里能看到的字段包括:
bash
APP_TYPE=mobile
ENTRY_FILE=index.mobile.js
ENABLE_TABBAR=true
ENABLE_LOGIN=true
EXCLUDE_ROBOT_SCREENS=true
和:
bash
APP_TYPE=robot
ENTRY_FILE=index.robot.js
ENABLE_TABBAR=false
ENABLE_LOGIN=false
EXCLUDE_ROBOT_SCREENS=false
这里有一个很值得借鉴的设计取向:
.env 在这类双端工程里,未必需要一开始就承载所有环境配置。
它完全可以先承担最核心的那部分职责:
- 当前端类型是什么
- 当前端是否显示某些 UI 结构
- 当前端是否进入登录流程
- 当前端是否需要排除另一端代码
换句话说,这里的 .env 更像是模式声明文件,而不是一切配置的总入口。
这也是为什么在真实代码里你会看到:
- HTTP 地址并不一定来自
.env - WebSocket 地址也未必来自
.env
这不代表方案有问题,只代表这套实现更偏"分端治理",而不是"完整多环境治理"。
4.3 scripts/load-env.js:把 .env 变成脚本可消费的数据
这一层通常会有一个很轻的装载脚本。真实代码的抽象大致是这样:
js
function loadEnvFile() {
const envPath = path.resolve(__dirname, '../.env');
if (!fs.existsSync(envPath)) {
return {
APP_TYPE: 'mobile',
ENABLE_TABBAR: 'true',
ENABLE_LOGIN: 'true',
};
}
// 逐行解析 KEY=VALUE
}
它的作用有两点:
- 把
.env读成结构化对象 - 给工程一个默认兜底模式,避免
.env缺失时完全失效
而且它不是只给一个地方用。
在真实工程里,这个脚本通常会被这些地方复用:
metro.config.jsscripts/inject-globals.jsscripts/code-separator.js
这意味着:
同一份模式配置,会同时影响打包配置、运行时全局变量和代码分离策略。
4.4 scripts/inject-globals.js:把模式送进 JS 运行时
有了 .env 还不够,应用运行起来之后,上层代码还得能读到它。
这一步通常会做成一个"全局注入脚本"。真实代码的核心逻辑大概是:
js
const envVars = loadEnvFile();
global.__APP_TYPE__ = envVars.APP_TYPE;
global.__ENABLE_TABBAR__ = envVars.ENABLE_TABBAR;
global.__ENABLE_LOGIN__ = envVars.ENABLE_LOGIN;
global.__EXCLUDE_ROBOT_SCREENS__ = envVars.EXCLUDE_ROBOT_SCREENS;
global.__EXCLUDE_MOBILE_SCREENS__ = envVars.EXCLUDE_MOBILE_SCREENS;
然后在 metro.config.js 里,把这个脚本挂到 bundle 启动前执行:
js
serializer: {
getModulesRunBeforeMainModule: () => [
require.resolve('./scripts/inject-globals.js'),
],
}
这一层的意义是:
.env只是磁盘文件inject-globals.js把它变成了运行时状态
从这一步开始,应用里的 TS / JS 代码已经不需要再关心 .env 文件本身,而只需要关心:
global.__APP_TYPE__global.__ENABLE_TABBAR__global.__ENABLE_LOGIN__
4.5 src/config/environment.ts:把裸全局变量封装成统一接口
这类工程一般还会再包一层,把全局变量变成运行期能力对象。
真实代码中就是这样做的:
ts
const APP_TYPE = global.__APP_TYPE__ || 'mobile';
export const Environment = {
APP_TYPE,
isMobile: APP_TYPE === 'mobile',
isRobot: APP_TYPE === 'robot',
enableTabBar: global.__ENABLE_TABBAR__ === 'true',
enableLogin: global.__ENABLE_LOGIN__ === 'true',
};
这一步的好处很直接:
- 页面层和应用壳层不需要再碰
global - 运行期判断从"读变量"升级成"读能力"
在真实代码里,Environment 还承担了一个更细的角色:
- 它不只是区分
mobile / robot - 还承担了部分运行期开关,比如是否启用 Tab、是否启用登录、是否排除某些页面
这里也能顺手看到一个真实工程里常见的现象:
environment.ts里读取了__EXCLUDE_MOBILE_SCREENS__- 但当前
.env.mobile/.env.robot中没有完整看到对应字段
这并不影响主链路成立,但说明环境字段体系仍有继续收敛空间。
4.6 双入口:index.mobile.js / index.robot.js
很多人第一次做双端工程时,会先想到双入口。这一步确实重要,但它只是开始。
真实代码里,两份入口文件非常清晰:
手机端入口
js
import App from './src/App';
global.__APP_TYPE__ = 'mobile';
global.__ENTRY_FILE__ = 'index.mobile.js';
global.__ENABLE_TABBAR__ = true;
global.__ENABLE_LOGIN__ = true;
AppRegistry.registerComponent(appName, () => App);
设备端入口
js
import RobotApp from './src/RobotApp';
global.__APP_TYPE__ = 'robot';
global.__ENTRY_FILE__ = 'index.robot.js';
global.__ENABLE_TABBAR__ = false;
global.__ENABLE_LOGIN__ = false;
AppRegistry.registerComponent(appName, () => RobotApp);
这一步做了两件事:
- 把模式映射成不同的入口文件
- 把入口映射成不同的根组件
而且入口文件本身还再次兜底设置了一遍全局变量。换句话说,这里形成了双保险:
- Metro 注入一遍
- 入口文件自己再设一遍
4.7 index.js 的存在,说明"兜底入口"在真实工程里很常见
这类工程往往还会保留一个 index.js。
真实代码里,它更像是默认兜底:
- 在某些普通构建里退回到默认入口
- 在部分平台未显式区分入口时作为 fallback
这点很重要,因为真实工程并不总是"每个平台都严格只走一条入口链路"。
很多时候,移动端会保留一个默认入口兜底,而设备端入口则更强调显式隔离。
4.8 双入口真正要落到"双应用壳"
入口文件的最终目的,不是换个文件名,而是挂到不同的应用壳上。
真实工程里的对应关系就是:
| 入口 | 应用壳 |
|---|---|
index.mobile.js |
src/App.tsx |
index.robot.js |
src/RobotApp.tsx |
这意味着两个端不是"同一个 App 里多几个 if",而是:
- 有两套不同的根组件
- 共享底层设施
- 顶层结构可以完全不同
这才是双端工程真正能长期维护的前提。
5. Metro 层分端:为什么双入口还不够?
很多 RN 双端方案第一次做出来时,往往停在双入口这一步。
但真实工程继续做下去,很快会发现一个问题:
入口切对了,不等于 bundle 真的干净了。
5.1 为什么双入口还不够
如果只有:
index.mobile.jsindex.robot.js
但 Metro 仍然把另一端的模块也纳入依赖图,就会带来几个常见问题:
- 当前端不该看到的页面仍可能被打进 bundle
- 另一端特有模块可能在当前端被静态分析到
- 某些 Hook、导航或原生依赖虽然没跑到,但已经进包了
- 一旦默认入口回退,甚至可能直接把另一端应用壳带进来
这也是为什么真实代码里,除了双入口,还有一整层代码分离策略。
5.2 scripts/code-separator.js:先告诉 Metro,哪些文件根本不该看
这类工程里比较稳妥的做法,是单独准备一个"代码分离器"脚本。
真实代码抽象后,大致是这样的:
js
if (APP_TYPE === 'robot') {
// 排除大部分手机端 screens
// 排除 TabNavigator
// 排除手机端专有 hook
} else {
// 排除 robot_screens
// 排除 RobotApp
}
真实规则比这个更细,但核心思想很稳定:
- 设备端模式下,主动排除大部分移动端页面和导航
- 移动端模式下,主动排除设备端页面和设备端应用壳
这一步的意义非常明确:
不要让 Metro"自己猜",而是明确告诉它:另一端的代码,这次就不要看。
5.3 metro.config.js:把模式、排除规则和别名一起串起来
真实工程里的 Metro 配置,不只是一个普通 RN 配置,而是分端链路的关键拼装层。
核心部分大致是这样:
js
const envVars = loadEnvFile();
const codeSeparator = new CodeSeparator();
resolver: {
blockList: exclusionList(getExclusionPatterns()),
alias: {
...(envVars.APP_TYPE === 'robot' && {
'../hooks/useHealth': path.resolve(__dirname, 'src/hooks/useHealth.robot.ts'),
}),
},
},
serializer: {
getModulesRunBeforeMainModule: () => [
require.resolve('./scripts/inject-globals.js'),
],
},
它同时完成了三件事:
- 读取当前端模式
- 通过
blockList排除另一端代码 - 在设备端模式下,把某些共享路径重定向到设备端专用实现
这就不是"我有两个入口文件"那么简单了,而是:
- 当前模式不同
- Metro 的依赖图也不同
- 相同 import 路径,甚至可以解析到不同实现
5.4 为什么这一层很关键
真实代码里的 Android 构建配置还专门加了注释,大意是:
- 某些任务如果没有正确识别 flavor
- 可能会退回到
index.js - 然后把不该跑的入口打进去,最终运行时崩溃
这类问题恰恰说明:
- 入口切换只是第一道防线
- Metro 代码分离是第二道防线
- 原生构建入口绑定是第三道防线
少了任何一层,双端工程都可能"能编译,但不可靠"。
5.5 运行期分端和 Metro 分端,不是一回事
这一点特别容易混淆。
| 机制 | 解决的问题 |
|---|---|
| 运行期分端 | 当前应用到底挂哪套根组件 |
| Metro 分端 | 当前 bundle 到底带哪些代码 |
前者决定"跑谁"。
后者决定"带谁进包"。
对于双端工程,这两件事必须同时成立。
6. Android 构建期分端:Flavor 才让"两个 APK"真正成立
如果说前面几层解决的是"JS 世界怎么分",那 Android 这层解决的就是:
怎么把双端模式真正做成两个不同的 Android 安装包。
从真实工程经验看,这也是 Android 侧最容易把双端方案真正落到安装包层的一环。
6.1 先看命令:Gradle task 已经按端拆开了
真实脚本映射很清楚:
| 命令 | 对应任务 |
|---|---|
npm run android:m |
installMobileDebug |
npm run android:r |
installRobotDebug |
npm run build:m |
assembleMobileRelease |
npm run build:r |
assembleRobotRelease |
这说明构建层从一开始就不是"先打一个包,再去区分",而是:
- mobile 有自己的一组安装/构建任务
- robot 也有自己的一组安装/构建任务
这会直接影响后面的:
- JS 入口选择
- flavor 选择
- Manifest 合并
- APK 输出目录
6.2 android/app/build.gradle:真正把分端构建串起来
这个文件是 Android 分端方案的核心。
第一段关键逻辑:根据 task 选择 JS 入口
真实代码里,有这样一段配置:
groovy
react {
def tasks = project.gradle.startParameter.taskNames.collect { it.toLowerCase() }.join(' ')
if (tasks.contains('robot')) {
entryFile = file("../../index.robot.js")
} else if (tasks.contains('mobile')) {
entryFile = file("../../index.mobile.js")
} else {
entryFile = file("../../index.js")
}
}
这段代码很有代表性,因为它没有把入口选择交给"约定俗成",而是直接绑定到了 Gradle task 名上:
- 任务名里带
robot,就打index.robot.js - 任务名里带
mobile,就打index.mobile.js - 其他情况退回默认
index.js
从工程稳定性看,这一步非常关键。
因为真实 RN/Gradle 场景里,默认入口回退并不少见。如果设备端包在构建时退回到默认入口,最容易发生的就是:
- 构建看起来成功
- 实际跑起来却拿到了移动端应用壳
第二段关键逻辑:productFlavors
真实代码里,Android flavor 的定义大致是这样:
groovy
productFlavors {
mobile {
applicationIdSuffix ".mobile"
versionNameSuffix "-mobile"
buildConfigField "String", "ENTRY_FILE", '"index.mobile.js"'
manifestPlaceholders = [screenOrientation: "unspecified"]
}
robot {
applicationIdSuffix ".robot"
versionNameSuffix "-robot"
buildConfigField "String", "ENTRY_FILE", '"index.robot.js"'
manifestPlaceholders = [screenOrientation: "portrait"]
}
}
这里体现了 flavor 真正的价值:
- 两个端不只是 JS 入口不同
- 它们已经变成了两个不同的 Android 构建目标
至少可以确认的差异包括:
| 差异项 | mobile | 设备端 |
|---|---|---|
applicationIdSuffix |
.mobile |
.robot |
versionNameSuffix |
-mobile |
-robot |
| 入口字段 | index.mobile.js |
index.robot.js |
| 横竖屏 placeholder | unspecified |
portrait |
换句话说,Flavor 做的不是"帮你切一下模式",而是:
把分端从 JS 概念,变成真正的原生构建差异。
6.3 为什么 Android 这一层特别适合做"分端做实"
因为 Android 原生工程天然就提供了几套非常适合分端的机制:
productFlavors- Manifest overlay
- flavor sourceSet
- Gradle task
- 独立 APK 输出目录
这几套机制组合起来以后,双端工程最核心的几个问题都能找到自然落点:
| 问题 | Android 的承接方式 |
|---|---|
| 入口不同 | react.entryFile |
| 包名不同 | applicationIdSuffix |
| 清单不同 | src/mobile / src/robot Manifest |
| 原生类不同 | flavor 源集 |
| 产物不同 | assembleMobileRelease / assembleRobotRelease |
这也是为什么 Android 侧通常更容易把"分端打包"做成闭环。
6.4 Manifest overlay:真正把系统能力分开
真实工程里,Android 清单拆成了三层:
android/app/src/main/AndroidManifest.xmlandroid/app/src/mobile/AndroidManifest.xmlandroid/app/src/robot/AndroidManifest.xml
这套分法很典型:
src/main
放的是两个端都需要的基础能力:
- 基础网络权限
- 蓝牙、相机、音视频等公共权限
- 主 Activity
src/mobile
放的是普通移动应用那一侧的补充配置。
从真实代码能确认的是:
- 仍是标准
MAIN + LAUNCHER - 没有 HOME
- 没有开机自启动
- 没有前台服务兜底
src/robot
放的是设备端专属配置。
从真实代码能明确看到:
- 增加
RECEIVE_BOOT_COMPLETED - 增加
FOREGROUND_SERVICE MainActivity同时具有LAUNCHER和HOME- 注册设备端专属的启动接收器和前台服务
- 强制竖屏
所以这一步回答了一个很关键的问题:
为什么双端打包不能只靠 JS 入口切换?
因为差异已经超出 JS 范围了。
除了页面和导航,Android 侧还要分:
- 包名
- 清单权限
- 系统角色
- 启动行为
- 自启动相关原生组件
这些都必须在原生层表达。
6.5 设备端专属原生源集,也是分端打包的一部分
真实代码里,设备端源集下还有专属原生类,比如:
android/app/src/robot/java/.../BootReceiver.ktandroid/app/src/robot/java/.../BootLaunchService.kt
这说明分端打包不仅是"入口不同",还是"最终 APK 内容就不同":
- mobile APK 根本不会包含这些设备端类
- 设备端 APK 才会打入这些系统级组件
这也是双端工程最有价值的地方之一:
运行差异没有停留在 JS 层,而是被真正固化进了安装包。
6.6 APK 产物目录如何分开
结合 assembleMobileRelease 和 assembleRobotRelease 两个任务,按 Android Gradle 默认输出规则,可以推导出产物目录分别位于:
text
android/app/build/outputs/apk/mobile/release/
android/app/build/outputs/apk/robot/release/
这里需要说清一个边界:
- 当前仓库里没有附带实际打好的 APK
- 但从 task 命名和 Android 输出规则看,目录层级可以明确推导出来
- 最终文件名仍需以本地构建结果为准
6.7 怎么确认真的打出了两个不同的 Android 应用包
如果只是"跑了两次构建命令",还不能证明最终得到的是两个真正不同的 Android 应用包。
更稳妥的确认方式,至少包括下面几项:
| 检查项 | mobile | 设备端 | 依据 |
|---|---|---|---|
| Gradle task | assembleMobileRelease |
assembleRobotRelease |
package.json |
| JS 入口 | index.mobile.js |
index.robot.js |
android/app/build.gradle |
| Flavor | mobile |
robot |
android/app/build.gradle |
applicationIdSuffix |
.mobile |
.robot |
android/app/build.gradle |
| Manifest overlay | src/mobile/AndroidManifest.xml |
src/robot/AndroidManifest.xml |
Android source set |
| 专属原生类 | 无设备端源集 | 包含开机启动/前台服务类 | android/app/src/robot/java/... |
| APK 输出目录 | outputs/apk/mobile/release/ |
outputs/apk/robot/release/ |
Gradle 默认产物路径 |
其中最关键的一点是 applicationIdSuffix。
基于真实配置可以明确看到:
- 手机端后缀是
.mobile - 设备端后缀是
.robot
这意味着两次构建产出的 APK 在 Android 系统里会被识别为两个不同的应用标识。基于这一点,可以合理推断它们不是"同一个包换了一套页面",而是两份可以独立安装、独立升级、独立承接系统角色的 Android 应用包。是否允许同机并存,最终仍需以实际安装验证和签名策略为准,但从 applicationId 设计上看,仓库已经具备这个前提。
6.8 mobile / 设备端 差异矩阵
把 Android 侧和 JS 侧放在一起看,双端差异可以抽象成这张表:
| 维度 | 手机端 | 设备端 |
|---|---|---|
| JS 入口 | index.mobile.js |
index.robot.js |
| 根组件 | src/App.tsx |
src/RobotApp.tsx |
| 运行期开关 | 启用登录、启用 Tab | 跳过登录、禁用 Tab |
| Metro 代码范围 | 排除设备端页面和应用壳 | 排除大部分手机端页面与导航 |
| Android flavor | mobile |
robot |
| 包名后缀 | .mobile |
.robot |
| 横竖屏策略 | 通用/未指定 | 竖屏 |
| Manifest 角色 | 标准 App | 设备端 App |
| 自启动能力 | 无 | 有 |
| 构建产物 | mobile/release |
robot/release |
6.9 Android 分端构建时序图
APK Manifest / SourceSet Flavor Gradle Metro 配置 .env npm script 开发者 APK Manifest / SourceSet Flavor Gradle Metro 配置 .env npm script 开发者 npm run build:r cp .env.robot .env ./gradlew assembleRobotRelease 根据 task 识别 robot,选择 index.robot.js 选择 robot flavor 合并 robot Manifest,纳入 robot 源集 输出 robot APK
7. 以一条真实命令为例:一次打包到底发生了什么?
理解一套方案最好的方式,往往不是看配置文件,而是顺着一条命令走一遍。
7.1 先看 npm run build:m
这条命令在真实代码里做的是:
text
cp .env.mobile .env
-> cd android
-> ./gradlew assembleMobileRelease
把它展开以后,实际发生的事情是:
- 把手机端模式写进
.env - 后续脚本和 Metro 都会读到
APP_TYPE=mobile - Android 构建任务选择
assembleMobileRelease android/app/build.gradle根据 task 名选择index.mobile.js- 构建时应用
mobileflavor - Manifest 合并时纳入
src/mobile/AndroidManifest.xml - 最终生成 mobile 的 Release APK
7.2 再看 npm run build:r
这条命令对应的是:
text
cp .env.robot .env
-> cd android
-> ./gradlew assembleRobotRelease
展开以后就是另一条链:
- 把设备端模式写进
.env - Metro 和运行期脚本都读到
APP_TYPE=robot - Android 构建任务选择
assembleRobotRelease android/app/build.gradle根据 task 名选择index.robot.js- 构建时应用
robotflavor - Manifest 合并时纳入
src/robot/AndroidManifest.xml - 设备端专属源集一起参与编译
- 最终生成 robot 的 Release APK
7.3 如果换成调试运行命令,会发生什么?
比如:
npm run start:mnpm run start:rnpm run android:mnpm run android:r
这几条命令的重点不是产物,而是把运行链切干净:
npm run start:m
text
切到 .env.mobile
-> 启动 Metro
-> 注入 mobile 运行参数
-> 生成 mobile 模式的打包配置
npm run start:r
text
切到 .env.robot
-> 启动 Metro
-> 注入 robot 运行参数
-> 设备端模式下排除移动端代码
npm run android:m
text
切到 .env.mobile
-> installMobileDebug
-> 选择 mobile 入口和 mobile flavor
-> 安装手机端调试包
npm run android:r
text
切到 .env.robot
-> installRobotDebug
-> 选择 robot 入口和 robot flavor
-> 安装设备端调试包
7.4 总体流程图
命令入口
start:m / start:r / android:m / android:r / build:m / build:r
复制 .env.mobile / .env.robot 到 .env
load-env.js 读取模式
inject-globals.js 注入运行时变量
index.mobile.js / index.robot.js 选择入口
App.tsx / RobotApp.tsx 选择应用壳
code-separator + metro.config.js 裁剪 bundle
Gradle task + productFlavor
Manifest overlay + 原生源集
APK 输出目录
8. 关键代码片段拆解
前面已经把整体链路讲完了,这里再把几个关键点收拢成更短的代码片段,看看它们各自解决了什么问题。
8.1 package.json 解决的是"如何稳定触发分端"
json
{
"scripts": {
"start:m": "cp .env.mobile .env && react-native start",
"start:r": "cp .env.robot .env && react-native start",
"android:m": "cp .env.mobile .env && react-native run-android --tasks installMobileDebug --appIdSuffix mobile",
"android:r": "cp .env.robot .env && react-native run-android --tasks installRobotDebug --appIdSuffix robot"
}
}
它解决的是:
- 不让开发者手动切模式
- 不让"忘记切环境"变成日常错误来源
- 让命令本身就带上端语义
8.2 environment.ts 解决的是"上层代码如何感知当前端模式"
ts
const APP_TYPE = global.__APP_TYPE__ || 'mobile';
export const Environment = {
isMobile: APP_TYPE === 'mobile',
isRobot: APP_TYPE === 'robot',
enableTabBar: global.__ENABLE_TABBAR__ === 'true',
enableLogin: global.__ENABLE_LOGIN__ === 'true',
};
它解决的是:
- 页面层不需要直接读全局变量
- 运行时判断有统一入口
8.3 双入口解决的是"根组件怎么分"
js
// index.mobile.js
import App from './src/App';
AppRegistry.registerComponent(appName, () => App);
js
// index.robot.js
import RobotApp from './src/RobotApp';
AppRegistry.registerComponent(appName, () => RobotApp);
它解决的是:
- 当前到底挂载哪套应用壳
- 两端能否从根上分开,而不是在同一个
App.tsx里堆满判断
8.4 Metro 代码分离解决的是"入口之外的污染问题"
js
if (APP_TYPE === 'robot') {
// 排除大部分手机端页面和导航
} else {
// 排除设备端页面和设备端应用壳
}
它解决的是:
- 另一端代码误进 bundle
- 当前端被另一端模块污染
- 构建虽然能过,但运行边界不清晰
8.5 android/app/build.gradle 解决的是"构建层怎么真正分开"
groovy
react {
if (tasks.contains('robot')) {
entryFile = file("../../index.robot.js")
} else if (tasks.contains('mobile')) {
entryFile = file("../../index.mobile.js")
}
}
productFlavors {
mobile { applicationIdSuffix ".mobile" }
robot { applicationIdSuffix ".robot" }
}
它解决的是:
- 当前打的是哪个端
- 当前 APK 属于哪个安装包身份
- 当前构建到底应该吃哪套入口和哪套原生配置
9. 这种方案的优点、局限和适用边界
从真实工程实现往回看,这套方案最大的优点,不是某个配置写得多巧,而是它的职责拆分是合理的。
9.1 这套方案最值得借鉴的地方
1. 同仓库复用率高
- 基础工程只维护一份
- 公共组件、Hook、网络层、工具层只维护一份
- 两个端的差异主要集中在入口、应用壳、原生配置和少量专属代码
2. 运行期分端和构建期分端拆得很清楚
- JS 层负责"当前跑哪套结构"
- Android 原生层负责"最终产出哪种安装包"
这种拆法比"所有逻辑都塞进环境变量"更稳。
3. Android 侧比较容易做成闭环
因为 flavor、Manifest overlay、sourceSet、Gradle task 这些机制天生就适合承接双端差异。
9.2 这套方案的局限
1. .env 目前更像模式开关,而不是完整配置中心
在真实代码里,很多通信地址和第三方配置仍然写在代码里,而不是完全来自环境变量。
这说明当前方案更偏:
- 分端治理
而不是:
- 完整多环境治理
2. 入口选择仍然依赖 task 命名约定
真实 android/app/build.gradle 是通过 Gradle task 名里是否包含 mobile 或 robot 来判断 entryFile 的。
这带来的好处是实现直接、改造成本低;但它也意味着:
- 自定义任务名时要保持命名约定
- CI 如果改了任务命名方式,入口选择逻辑也要同步检查
- 一旦没有命中,可能会退回默认
index.js
所以这条链路虽然成立,但也需要团队在脚本和 CI 上保持一致性。
3. 旧文档已经落后于代码
在真实仓库里,可以确认:
README.mdSOURCE_SUMMARY.mdVERSION_UPDATE_README.md
都和当前实现存在不同程度的滞后。
最直接的例子就是脚本命名:
- 文档里的脚本表达已经不是当前真实可执行的入口
- 真正应该以
package.json里的start:m、start:r、android:m、android:r为准
4. Android Release 签名还没有完全生产化
当前 android/app/build.gradle 的 release 仍然使用 debug signing config。
这说明:
- 双端构建链路是通的
- 但正式发布链仍有继续完善空间
5. 包管理存在双锁文件
仓库同时存在:
package-lock.jsonyarn.lock
这通常意味着团队历史上混用过 npm 和 Yarn,后续维护时最好尽快统一。
9.3 适合哪些 RN 项目
这类方案更适合:
- 两个端共享大量基础代码
- 差异主要集中在顶层结构和原生能力
- Android 侧明确需要两个安装包
- 团队希望保留单仓库协作和公共能力沉淀
如果两个端已经在业务、依赖、原生能力上几乎完全分裂,那拆成两个仓库反而可能更省心。
10. 对类似 RN 项目的可复用建议
如果你也在做一类"手机端 + 设备端"或"管理端 + 终端"的 RN 工程,这套真实代码落地抽象出来的经验,最值得借鉴的不是某个文件,而是一套实施顺序。
第一步:先把模式开关定下来
不要一开始就引入复杂环境平台。
先有:
.env.mobile.env.deviceAPP_TYPE
先把"我是谁"说清楚,再去治理剩下的问题。
第二步:尽早拆双入口和双应用壳
如果两个端的顶层结构已经明显不同,就不要硬塞到一个 App.tsx 里。
最稳妥的做法通常是:
index.mobile.jsindex.device.jssrc/App.tsxsrc/DeviceApp.tsx
第三步:双入口之后,立刻补上 Metro 代码分离
这是最容易被忽略,但最容易在后期出问题的一步。
没有代码分离,双入口只能解决"怎么跑",解决不了"包里到底装了什么"。
第四步:Android 尽早引入 flavor
只要两端在 Android 侧出现以下任一差异,就应该认真考虑 flavor:
- 包名不同
- 清单权限不同
- 启动模式不同
- 系统角色不同
- 原生源集不同
不要试图只靠 JS 入口去承接所有差异。
第五步:把 Android 产物校验和发布链补完整
如果目标是稳定打出两个可交付的 Android 包,最后一步不要停在"本地能 assemble 成功"。
更稳妥的做法是继续补齐:
- Release 签名配置
- 构建后自动校验
applicationId - APK 输出目录和文件名规范
- CI 中分别构建
mobile和robot
11. 总结
如果把这套方案浓缩成一句话,可以这样理解:
RN 双端一体工程的关键,不是"多写两个入口文件",而是把运行时分流、Metro 代码裁剪和原生构建分支串成一条完整链路。
从真实代码抽象出来,最值得记住的其实只有三点:
- 运行期分端和构建期分端一定要拆开设计
- 双入口只是开始,Metro 代码分离和 Android Flavor 才能把方案做实
- 要想真正得到两个不同的 Android 应用包,必须把
entryFile、Flavor、Manifest、源集和产物目录串成一条链
如果你的 RN 工程也需要同时支撑两种终端形态,这套思路的可复用价值在于:
- 它不依赖复杂平台
- 它尊重 React Native 和 Android 的原生机制
- 它能在保持单仓库复用的前提下,把双端差异控制在可维护范围内
说到底,双端打包不是"多打一份包",而是一次完整的工程分层设计。只要这条链路拆得对,后面的维护成本、扩展能力和构建稳定性,都会好很多。