React Native 双端一体工程,如何实现分端运行与分端打包?

当一个 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 和源集

从工程角度看,这套骨架有两个非常重要的特征:

  1. JS 层先把运行形态分开
  2. Android 原生层再把安装包真正分开

4. 运行期分端:先决定"当前到底是哪个端"

这一层解决的是一个很基础,但也最容易被低估的问题:

当开发者执行 npm run start:mnpm 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
}

它的作用有两点:

  1. .env 读成结构化对象
  2. 给工程一个默认兜底模式,避免 .env 缺失时完全失效

而且它不是只给一个地方用。

在真实工程里,这个脚本通常会被这些地方复用:

  • metro.config.js
  • scripts/inject-globals.js
  • scripts/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);

这一步做了两件事:

  1. 把模式映射成不同的入口文件
  2. 把入口映射成不同的根组件

而且入口文件本身还再次兜底设置了一遍全局变量。换句话说,这里形成了双保险:

  • 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.js
  • index.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'),
  ],
},

它同时完成了三件事:

  1. 读取当前端模式
  2. 通过 blockList 排除另一端代码
  3. 在设备端模式下,把某些共享路径重定向到设备端专用实现

这就不是"我有两个入口文件"那么简单了,而是:

  • 当前模式不同
  • 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.xml
  • android/app/src/mobile/AndroidManifest.xml
  • android/app/src/robot/AndroidManifest.xml

这套分法很典型:

src/main

放的是两个端都需要的基础能力:

  • 基础网络权限
  • 蓝牙、相机、音视频等公共权限
  • 主 Activity

src/mobile

放的是普通移动应用那一侧的补充配置。

从真实代码能确认的是:

  • 仍是标准 MAIN + LAUNCHER
  • 没有 HOME
  • 没有开机自启动
  • 没有前台服务兜底

src/robot

放的是设备端专属配置。

从真实代码能明确看到:

  • 增加 RECEIVE_BOOT_COMPLETED
  • 增加 FOREGROUND_SERVICE
  • MainActivity 同时具有 LAUNCHERHOME
  • 注册设备端专属的启动接收器和前台服务
  • 强制竖屏

所以这一步回答了一个很关键的问题:

为什么双端打包不能只靠 JS 入口切换?

因为差异已经超出 JS 范围了。

除了页面和导航,Android 侧还要分:

  • 包名
  • 清单权限
  • 系统角色
  • 启动行为
  • 自启动相关原生组件

这些都必须在原生层表达。

6.5 设备端专属原生源集,也是分端打包的一部分

真实代码里,设备端源集下还有专属原生类,比如:

  • android/app/src/robot/java/.../BootReceiver.kt
  • android/app/src/robot/java/.../BootLaunchService.kt

这说明分端打包不仅是"入口不同",还是"最终 APK 内容就不同":

  • mobile APK 根本不会包含这些设备端类
  • 设备端 APK 才会打入这些系统级组件

这也是双端工程最有价值的地方之一:

运行差异没有停留在 JS 层,而是被真正固化进了安装包。

6.6 APK 产物目录如何分开

结合 assembleMobileReleaseassembleRobotRelease 两个任务,按 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

把它展开以后,实际发生的事情是:

  1. 把手机端模式写进 .env
  2. 后续脚本和 Metro 都会读到 APP_TYPE=mobile
  3. Android 构建任务选择 assembleMobileRelease
  4. android/app/build.gradle 根据 task 名选择 index.mobile.js
  5. 构建时应用 mobile flavor
  6. Manifest 合并时纳入 src/mobile/AndroidManifest.xml
  7. 最终生成 mobile 的 Release APK

7.2 再看 npm run build:r

这条命令对应的是:

text 复制代码
cp .env.robot .env
-> cd android
-> ./gradlew assembleRobotRelease

展开以后就是另一条链:

  1. 把设备端模式写进 .env
  2. Metro 和运行期脚本都读到 APP_TYPE=robot
  3. Android 构建任务选择 assembleRobotRelease
  4. android/app/build.gradle 根据 task 名选择 index.robot.js
  5. 构建时应用 robot flavor
  6. Manifest 合并时纳入 src/robot/AndroidManifest.xml
  7. 设备端专属源集一起参与编译
  8. 最终生成 robot 的 Release APK

7.3 如果换成调试运行命令,会发生什么?

比如:

  • npm run start:m
  • npm run start:r
  • npm run android:m
  • npm 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 名里是否包含 mobilerobot 来判断 entryFile 的。

这带来的好处是实现直接、改造成本低;但它也意味着:

  • 自定义任务名时要保持命名约定
  • CI 如果改了任务命名方式,入口选择逻辑也要同步检查
  • 一旦没有命中,可能会退回默认 index.js

所以这条链路虽然成立,但也需要团队在脚本和 CI 上保持一致性。

3. 旧文档已经落后于代码

在真实仓库里,可以确认:

  • README.md
  • SOURCE_SUMMARY.md
  • VERSION_UPDATE_README.md

都和当前实现存在不同程度的滞后。

最直接的例子就是脚本命名:

  • 文档里的脚本表达已经不是当前真实可执行的入口
  • 真正应该以 package.json 里的 start:mstart:randroid:mandroid:r 为准

4. Android Release 签名还没有完全生产化

当前 android/app/build.gradlerelease 仍然使用 debug signing config。

这说明:

  • 双端构建链路是通的
  • 但正式发布链仍有继续完善空间

5. 包管理存在双锁文件

仓库同时存在:

  • package-lock.json
  • yarn.lock

这通常意味着团队历史上混用过 npm 和 Yarn,后续维护时最好尽快统一。

9.3 适合哪些 RN 项目

这类方案更适合:

  • 两个端共享大量基础代码
  • 差异主要集中在顶层结构和原生能力
  • Android 侧明确需要两个安装包
  • 团队希望保留单仓库协作和公共能力沉淀

如果两个端已经在业务、依赖、原生能力上几乎完全分裂,那拆成两个仓库反而可能更省心。


10. 对类似 RN 项目的可复用建议

如果你也在做一类"手机端 + 设备端"或"管理端 + 终端"的 RN 工程,这套真实代码落地抽象出来的经验,最值得借鉴的不是某个文件,而是一套实施顺序。

第一步:先把模式开关定下来

不要一开始就引入复杂环境平台。

先有:

  • .env.mobile
  • .env.device
  • APP_TYPE

先把"我是谁"说清楚,再去治理剩下的问题。

第二步:尽早拆双入口和双应用壳

如果两个端的顶层结构已经明显不同,就不要硬塞到一个 App.tsx 里。

最稳妥的做法通常是:

  • index.mobile.js
  • index.device.js
  • src/App.tsx
  • src/DeviceApp.tsx

第三步:双入口之后,立刻补上 Metro 代码分离

这是最容易被忽略,但最容易在后期出问题的一步。

没有代码分离,双入口只能解决"怎么跑",解决不了"包里到底装了什么"。

第四步:Android 尽早引入 flavor

只要两端在 Android 侧出现以下任一差异,就应该认真考虑 flavor:

  • 包名不同
  • 清单权限不同
  • 启动模式不同
  • 系统角色不同
  • 原生源集不同

不要试图只靠 JS 入口去承接所有差异。

第五步:把 Android 产物校验和发布链补完整

如果目标是稳定打出两个可交付的 Android 包,最后一步不要停在"本地能 assemble 成功"。

更稳妥的做法是继续补齐:

  • Release 签名配置
  • 构建后自动校验 applicationId
  • APK 输出目录和文件名规范
  • CI 中分别构建 mobilerobot

11. 总结

如果把这套方案浓缩成一句话,可以这样理解:

RN 双端一体工程的关键,不是"多写两个入口文件",而是把运行时分流、Metro 代码裁剪和原生构建分支串成一条完整链路。

从真实代码抽象出来,最值得记住的其实只有三点:

  1. 运行期分端和构建期分端一定要拆开设计
  2. 双入口只是开始,Metro 代码分离和 Android Flavor 才能把方案做实
  3. 要想真正得到两个不同的 Android 应用包,必须把 entryFile、Flavor、Manifest、源集和产物目录串成一条链

如果你的 RN 工程也需要同时支撑两种终端形态,这套思路的可复用价值在于:

  • 它不依赖复杂平台
  • 它尊重 React Native 和 Android 的原生机制
  • 它能在保持单仓库复用的前提下,把双端差异控制在可维护范围内

说到底,双端打包不是"多打一份包",而是一次完整的工程分层设计。只要这条链路拆得对,后面的维护成本、扩展能力和构建稳定性,都会好很多。

相关推荐
冰暮流星2 小时前
javascript之dom访问属性
开发语言·javascript·dubbo
一只小阿乐2 小时前
TypeScript中的React开发
前端·javascript·typescript·react
Highcharts.js2 小时前
Highcharts客户端导出使用文档说明|图表导出模块讲解
前端·javascript·pdf·highcharts·图表导出
华仔啊3 小时前
GitHub 25k Star!这款开源录屏工具,免费无水印可商用,彻底告别付费
javascript
一只小阿乐3 小时前
react路由中使用context
前端·javascript·react.js·context 上下文
Hilaku3 小时前
一周狂揽40K+ Star⭐ 的 Pretext 到底有多变态?
前端·javascript·html
前端郭德纲3 小时前
JavaScript 原型相关属性详解
开发语言·javascript·原型模式
533_3 小时前
适用于vue3的拖拽插件:vue-draggable-plus, vuedraggable@next
javascript·vue.js
早點睡3903 小时前
ReactNative项目OpenHarmony三方库集成实战:react-native-shadow-2
javascript·react native·react.js