Expo Modules 与原生扩展
这一章解决一个问题:Expo 官方模块和第三方库都没覆盖到的原生能力,怎么补上。路径有三条——config plugin 改原生工程文件、Expo Modules API 写运行时桥接、inline module 直接在项目里塞原生代码。写之前先想清楚该不该写,写完要想清楚放在哪、升级时会不会丢。TurboModule / 老架构 / codegen 那条 bare 路线在 react-native/09-原生模块与平台代码.md 有完整展开,本章不重复。
基线:Expo SDK 58(2026-10-15 发布,React Native 0.88),API 名与目录约定均已按 2026-10-20 的 docs.expo.dev /modules/* 页面核实。
1. 动手前先问三件事
按顺序问,问到"是"就停:
- 官方模块覆盖了吗? 90 个 expo-* 包(相机、文件、SQLite、传感器、通知……)见
08-SDK模块速查.md。多数"我以为要写原生"的需求在这里就有答案。 - 第三方 RN 库能用吗? 带原生代码的库装完就靠 autolinking 自动接进工程(前提:development build,Expo Go 只含内置模块,见
05-Expo-Go与开发构建.md)。新架构支持与维护状态去 https://reactnative.directory 查,见本章第 6 节。 - 都没有,才轮到写原生。 其中又分两小类:只需要往原生工程文件里加配置(权限、plist 键、Gradle 属性)——那是 config plugin 的活,语法见
03-应用配置与插件.md;需要运行时的原生代码——这才是本章的 Expo Modules。要下沉 C++ 的模块,官方建议反而走 TurboModules(Expo 文档原话:依赖底层机制用 C++ 就选 TurboModules,追求开发体验且可以依赖expo包就用 Expo Modules API)。
2. CNG 下的原生目录:原生改动只有两个归宿
npx expo prebuild 根据 app 配置 + plugins 生成 android/、ios/。围绕这两个目录的心智模型只有一条:原生改动只有两个归宿——plugins/(构建期配置)或 expo-modules/(运行时能力),写在 android/、ios/ 目录里的东西都是临时文件。
类比:CNG 生成的原生工程 ≈ 构建产物(dist/)。你可以提交 dist/,但每次重新构建它都会被覆盖,所以正确动作是把改动下沉到源头。
.gitignore 的两种选择:
- 不提交(推荐):
android/、ios/进.gitignore。升级 SDK 最省事——官方升级指引就一句"把旧 SDK 生成的这两个目录删掉,下次构建自动重生成"(见11-升级与诊断.md)。代价是所有原生定制必须收敛进 config plugin 或本地 module,没有"直接改工程文件"这条捷径。 - 提交:适合存量 bare 项目半路接 Expo,或者你有 plugin 机制表达不了的极端定制。代价是每次 prebuild 都要手动处理 diff,且
--clean从此与你告别。
注意:
npx expo prebuild --clean会先删掉再重新生成整个android/、ios/。手工写进 AppDelegate、gradle.properties 的任何改动都会被抹掉且不进 git 就找不回来。本地 module 和plugins/目录不受影响——这正是"改动只有两个归宿"这个心智的由来。临时补丁可以用 patch-project(react-native patch-project),但它是技术债,尽快还成 plugin。
3. Expo Modules API(1.0 DSL,当前主线)
3.1 设计哲学
Expo Modules API 想消灭的是"写一个原生模块要跨四五个体系"这件事。对比 bare 路线手写 TurboModule(JS spec + codegen + Swift/Kotlin 实现 + 注册,见 react-native/09-原生模块与平台代码.md),Expo 的做法是:
- TS 与原生同包分发。一个 npm 包(或 app 内
modules/目录)里src/是 TS 公开 API,ios/、android/是原生实现,expo-module.config.json告诉 autolinking 注册哪些类。 - autolinking 自动注册。没有 AppDelegate 手工挂钩、没有 codegen 步骤,
npx expo prebuild时(Android 在 Gradle 配置时)自动发现并接入。 - JS 侧拿到的是代理对象。
requireNativeModule('Xxx')返回的 JS 对象直接代理到原生实例,函数/属性/事件都在原生模块定义里声明一次。 - 类型安全在编译期做参数转换:非法实参会在原生侧抛
ArgumentCastException,JS 端表现为 rejected Promise,错误链直接指出第几个参数、差在哪个类型。
3.2 脚手架:create-expo-module
两种形态,按需选:
# 形态一:本地模块(只给这一个 app 用,发布不进 npm)
# 在项目根目录(package.json 所在处)运行
npx create-expo-module@latest --local
# 形态二:独立模块包(自带 example app,可发布/多项目复用)
npx create-expo-module@latest my-module--local 生成到 modules/(可用 package.json 的 expo.autolinking.nativeModulesDir 改路径),结构:
modules/my-module/
android/ # Kotlin 实现
ios/ # Swift 实现 + podspec
src/ # TS 公开 API
expo-module.config.json # autolinking 注册声明
index.ts独立包在此基础上多一个 example/ 应用和包元数据,附带脚本:build(编译 TS)、clean、test、prepare(发布前构建产物)、open:ios / open:android。--features 可选择性生成 Function/AsyncFunction/Event/View/SharedObject/Constant 示例代码(all 全给),建议至少看 Function 与 AsyncFunction。
本地模块加完后如果 app 还没有原生工程,跑一次 npx expo prebuild;改过 expo-module.config.json 或新增原生文件后,iOS 要 npx pod-install。
3.3 TS 侧:ProxyModule 包装模式
约定是:不要裸导 requireNativeModule 的结果,声明类型时继承 expo 包 re-export 的 NativeModule<Events> 泛型类(docs 教程实测写法),src/index.ts 再包一层稳定 API 面。以官方"设置持久化"教程的骨架为例:
// src/ExpoSettingsModule.ts —— 内部代理声明,不直接给使用者
import { NativeModule, requireNativeModule } from 'expo';
import { ExpoSettingsModuleEvents, Theme } from './ExpoSettings.types';
declare class ExpoSettingsModule extends NativeModule<ExpoSettingsModuleEvents> {
setTheme: (theme: Theme) => void;
getTheme: () => Theme;
}
// 这个调用从 JSI 加载原生模块对象
export default requireNativeModule<ExpoSettingsModule>('ExpoSettings');// src/index.ts —— 对外 API 面:校验、文档注释、事件订阅封装都放这层
import { EventSubscription } from 'expo-modules-core';
import ExpoSettingsModule from './ExpoSettingsModule';
import { Theme, ThemeChangeEvent } from './ExpoSettings.types';
export function addThemeListener(
listener: (event: ThemeChangeEvent) => void
): EventSubscription {
return ExpoSettingsModule.addListener('onChangeTheme', listener);
}
export function getTheme(): Theme {
return ExpoSettingsModule.getTheme();
}
export function setTheme(theme: Theme): void {
return ExpoSettingsModule.setTheme(theme);
}顺带一提:老资料里 requireNativeModule 多从 expo-modules-core 导入,现行 docs 教程统一从 expo 导入(EventSubscription 这类类型仍从 expo-modules-core 拿),两种写法目前都有效,新代码跟 docs 走。
3.4 Swift 侧:Module 定义器
Module 子类 + definition() 返回一个 result builder DSL。常用定义器:Name、Constant、Function(同步)、AsyncFunction(返回 Promise)、Property(getter/setter)、Events + sendEvent、View,以及 OnCreate / OnAppEntersBackground 等生命周期钩子。一个可编译级的电量小模块(UIDevice 只允许主线程访问,正好演示 runOnQueue(.main)):
// ios/BatteryModule.swift
import ExpoModulesCore
import UIKit
public class BatteryModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoBattery")
OnCreate {
UIDevice.current.isBatteryMonitoringEnabled = true
}
Events("onLevelChange")
Property("level") {
Double(max(UIDevice.current.batteryLevel, 0))
}
AsyncFunction("getLevelAsync") { () -> Double in
Double(max(UIDevice.current.batteryLevel, 0))
}.runOnQueue(.main)
Function("setLowPowerHint") { (hint: String) -> Void in
UserDefaults.standard.set(hint, forKey: "lowPowerHint")
sendEvent("onLevelChange", ["hint": hint])
}
}
}Property 可变版本是链式的:Property("foo").get { ... }.set { (v: String) in ... }。View 定义器让模块顺带导出原生视图(Prop、回调事件、挂在 ref 上的 AsyncFunction):
View(UITextView.self) {
Prop("text") { (view: UITextView, text: String) in view.text = text }
AsyncFunction("focus") { (view: UITextView) in view.becomeFirstResponder() }
}(以上定义器语法均为 docs /modules/module-api 参考页原文示例。)
3.5 Kotlin 侧:对称的 ModuleDefinition DSL
同一个模块的 Android 面,BatteryManager 甚至不需要主线程:
// android/src/main/java/expo/modules/battery/BatteryModule.kt
package expo.modules.battery
import android.content.Context
import android.os.BatteryManager
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class BatteryModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoBattery")
Property("level") {
batteryLevel()
}
AsyncFunction("getLevelAsync") {
batteryLevel()
}
}
private val context
get() = requireNotNull(appContext.reactContext)
private fun batteryLevel(): Int {
val manager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
return manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
}
}Kotlin 侧的差异点:同步函数体是带参数类型的 lambda(Function("add") { a: Int, b: Int -> a + b });协程写法是 AsyncFunction("fetch") Coroutine { ... } 直接接 suspend 体,作用域绑定模块生命周期;带显式 Promise 参数时注意从 expo.modules.kotlin 而不是 expo.modules.core 导入 Promise 类(docs 专门有这条警告)。
3.6 Promise 与错误处理约定
规则很短,官方参考页原文表述:"(AsyncFunction)立即以返回值 resolve,如果抛异常则 reject"。也就是说:
AsyncFunction("setTheme") { (theme: String) -> Void in
guard theme.count <= 20 else {
// Kotlin 侧同理:函数体内 throw Exception("...")
throw Exception("Theme name is too long")
}
UserDefaults.standard.set(theme, forKey: "theme")
}JS 端 Settings.setTheme('x'.repeat(30)) 得到一个 rejected Promise,message 就是抛出的文本。同理,参数转换失败自动 reject(前面枚举转换错误链的例子)。需要给 JS 端错误码做程序化分支时,用显式 Promise 参数自己 resolve/reject(promise.resolve(value) / promise.reject(code, message)),但这是逃生舱——默认让"返回值即 resolve、throw 即 reject"跑到底,API 面反而干净。
3.7 版本兼容与工具链(2026-10-20 实测)
expo-modules-core与expo-modules-autolinking已与 SDK 同号发布:registry 实测 latest 分别为 58.0.23 / 58.0.13(sdk-55 起切到 5x 系列,更早的 SDK 54 时代 core 还是 3.x)。模块包的 peerDependency 跟着 app 的 SDK 号装即可,npx expo install会自动对齐。- 脚手架与构建工具
expo-module-scripts则停在了 56.x 系列(registry 实测 latest=56.0.3、next=56.0.8,没有 57/58 tag)——工具与运行时解耦了,遇到构建怪问题先核对脚手架实际装到的版本,别假设它和 SDK 同号。 - 1.0 DSL 不会被废弃:官方口径是 2.0 成为"默认写法"之后,1.0 对已用它的代码继续有效(early-look 博客原话)。存量模块不必恐慌性迁移。
4. Expo Modules 2.0(beta):注解取代 DSL
SDK 58 把 Expo Modules 2.0 推进到 iOS + Android 双平台 beta(iOS 宏在 57 是实验特性)。它改的是"你写的那部分":不再用 purpose-built DSL 描述模块对外形状,而是写一个普通的 Swift/Kotlin 类,给想暴露的方法和属性加注解。官方 early-look 博客:https://expo.dev/blog/an-early-look-at-expo-modules-2-0 (2026-09-08,Tomasz Sapeta)。
核心宏:@ExpoModule、@JS、@Record、@SharedObject、@Event。形状大致如下(示意——截至 2026-10-20 2.0 的正式文档尚未落地,官方明言"early-look 博客是文档可用前的最佳指南",最终以宏实现与后续文档为准):
// Expo Modules 2.0 风格:没有 definition(),没有 Name()
// (默认用类名),没有 result builder
@ExpoModule
class SettingsModule {
// Function 和 AsyncFunction 都变成普通方法:
// 加 async 就是 JS 端返回 Promise 的函数
@JS
func getTheme() -> String {
UserDefaults.standard.string(forKey: "theme") ?? "system"
}
@JS
func setTheme(_ theme: String) async {
UserDefaults.standard.set(theme, forKey: "theme")
onChange(["theme": theme]) // 事件就是一个可调用属性,调用即派发
}
@Event
var onChange: ([String: Any]) -> Void
// 属性即 Property:var 可读写,let / 只读 var 在 JS 端只读
@JS
var theme: String = "system"
}对应的映射规则(博客原文逐条可查):Record 是普通 Swift struct(非 private/static/lazy 的存储属性即字段,必填/可选看声明);SharedObject 是 JS 与 Swift 同时持有实例的类,@JS init() 变成 JS 构造函数;payload 类型编译期校验,不合法直接构建失败——这一点比 1.0 的运行期 cast 异常友好得多。
4.1 为什么快:绑定在构建期生成
1.0 的运行期路径本质是反射:每个参数包进引用计数容器、过一遍动态类型转换器、组装完才能调你的函数。@JS 宏在构建期读你的函数签名,生成的绑定直接从 JS 运行时放置数据的位置读取并转换。基准数字(引自官方材料,注明来源实验条件):
- iOS:同一 SDK 下,同步调用比 1.0 DSL 快 2.5–5.6 倍——iPhone 16 Pro、iOS 27、Release 构建、precompiled frameworks,10 万次调用总时长(early-look 博客)。叠加在 SDK 56–58 的底座提速之上:56 砍掉 Objective-C++ 层(调用快 1.5–2 倍、追平 TurboModules),58 又对简单值调用、长字符串(最高 3.8×)、Promise 创建做了三项优化(changelog,“什么都没做、升级就变快”)。
- Android:十二项微基准全部快于 1.0,从 100 个数字的数组传参 1.2× 到数字事件发射 39.9×;且十二项全部快于 TurboModule——无参调用 112 ns vs 2,837 ns,20 个对象的数组 10.7 µs vs 149.8 µs,对象事件 963 ns vs 8,080 ns(changelog 原文数字)。
4.2 “适合 agent 生成"是官方论点
博客专门论证了这一点:模型在十几年存量的 Swift/Kotlin 上训练,1.0 自定义 DSL 的语料太少、要占 context 教;2.0 的 API 面就是语言本身,且类型错误由编译器在声明处报出来,generate→报错→修复的循环最短。原话值得记住:”An agent that can write an iOS app can write an Expo module." 迁移也不用手改:expo-migrate-module skill(属 expo-experiments 插件)可以让 coding agent 把模块的 Swift 侧从 1.0 迁到 2.0,保持 JS API 不变,迁不动的留在 definition() 里并列进报告。
4.3 增量共存与下一步
两套 API 可以共存于同一模块:保留 definition(),加上 @ExpoModule,逐个函数/属性搬进 @JS,合并生效——不需要一次性重写。路线图上的下一块:View(@ExpoView + 类型化 @ViewProps struct)与从原生签名生成 TS 声明(与"TS spec 驱动 codegen"的 RN 路线方向相反,类型源自原生代码,TS 声明变成构建产物)。截至 2026-10-20 这两项都还没落地,写新 View 仍用 1.0 的 View 定义器。
5. Inline modules:不发包,在项目里直接写原生
截至 SDK 58,inline modules 仍是实验特性(docs 标注 SDK 56 起可用、API 可能变),但它解决了一个真实痛点:给单个 app 写三五十行原生胶水,不值得建一个 module 包。
开启方式(app.json / app.config):
{
"expo": {
"experiments": {
"inlineModules": {
"watchedDirectories": ["app", "src"]
}
}
}
}然后把 Kotlin/Swift 文件直接放进被监视的目录,npx expo prebuild 后 Expo 自动发现、编译进 dev build。命名是硬约定:文件名 = 类名 = 原生模块名(全 app 唯一),Name() 与文件名一致时可省略:
// app/FirstInlineModule.swift
internal import ExpoModulesCore
class FirstInlineModule: Module {
public func definition() -> ModuleDefinition {
Constant("Hello") {
return "Hello iOS inline modules!"
}
}
}JS 侧 requireNativeModule('FirstInlineModule')(从 expo 导入)即可使用;同样支持在目录里放 inline View。限制要清楚:改动原生代码要重新 build(没有 Fast Refresh 一说);Expo Go 里不可用;xcodeProjectTargets 可把 inline 模块同时加进 Widget 等额外 target。
官方自己就重度使用这套机制:expo-app-intents 要求把 intents 声明为 app-intents 目录下的 Swift inline modules(Apple 在构建期才能发现它们),初始化跑 npx expo-app-intents init。“实验特性"与"官方旗舰功能的实现方式"同框出现,配合 agent 直接往 app 目录里生成一小段 Swift 的工作流,inline module 大概率是这两年被写得最多的原生入口——但正式转正前,把它用在 app 私有代码上,别指望它跨项目分发。
6. 第三方库接入与补 plugin
6.1 直接装:绝大多数情况
- 纯 JS 库:
npm install就完事,与 Expo 无摩擦。 - 带原生代码的库:
npx expo install <lib>,Gradle/CocoaPods 层由 autolinking 自动接好(“装包 + 重跑 pod install"就是全剧终),然后打一个 development build。Expo Go 不支持——它的模块集合是固定的(见05-Expo-Go与开发构建.md)。 - 接入前查 https://reactnative.directory :库的新架构支持状态、维护活跃度、Expo config plugin 有无,一屏看全。
6.2 老库出问题的排查路径
autolinking 的解析顺序(docs 原文):① react-native.config.js 里显式给了 root 的 RN 模块;② searchPaths 指定的目录;③ nativeModulesDir(默认 ./modules/)的本地模块;④ 递归解析 dependencies + peerDependencies(同 Node 解析算法)。个别老库挂掉通常是两类原因:缺 react-native.config.js(RN 侧的原生工程清单,老库缺了或字段过时,autolinking 找不到入口);或 Expo 侧缺 expo-module.config.json(不过 Expo Autolinking 同时链接 Expo modules 与普通 RN modules,多数情况不需要这个文件)。排查动作:
- 看
npx expo prebuild/ Gradle sync / pod install 的输出,确认该包有没有被解析、链接到哪; - 配置都在 package.json 顶层
expo.autolinking(支持android/ios/apple按平台覆盖,可落到 Podfile 的use_expo_modules!与 settings.gradle 的useExpoModules参数):exclude摘掉不想要的包(顺带减包体)、searchPaths指到 monorepo 的 packages 目录(注意 SDK 54 起不再默认向上搜 node_modules,monorepo 要显式列出); - 确认库对 RN 版本的最低要求(
reactnative.directory+ 库的 README),新架构不支持的库看有没有 fork/替代,最后手段才是react-native.config.js里给库手工指定 root。
6.3 给不带 plugin 的库补一个 config plugin
何时写:库的 README 里出现"setup 需要手工改 AndroidManifest / Info.plist / Podfile"这类步骤时——这些手工改动在 CNG 下活不过 --clean,正确解法是替它写个 plugin(哪怕先写在自己项目的 plugins/ 里,再考虑提 PR 回库)。withX helpers 的语法与 mods 机制在 03-应用配置与插件.md 讲透,这里只给"为库补配置"的最小实例——往 Info.plist 加键、往 AndroidManifest 加 meta-data(示例结构按 docs /config-plugins/plugins 教程核实):
// plugins/withAcmeSdk.ts —— 库要求宿主 App 声明的两处配置
import { ConfigPlugin, withAndroidManifest, withInfoPlist } from 'expo/config-plugins';
const withAcmeSdk: ConfigPlugin<{ apiKey: string }> = (config, { apiKey }) => {
config = withInfoPlist(config, cfg => {
cfg.modResults.ACME_API_KEY = apiKey;
return cfg;
});
return withAndroidManifest(config, cfg => {
const app = cfg.modResults.manifest.application?.[0];
if (app) {
app['meta-data'] = app['meta-data'] ?? [];
app['meta-data'].push({
$: { 'android:name': 'com.acme.API_KEY', 'android:value': apiKey },
});
}
return cfg;
});
};
export default withAcmeSdk;加 <uses-permission> 同理,操作 manifest 的对应数组即可。库作者侧:plugin 与库同包发布(根级 app.plugin.js 做入口,plugin 实现单独 plugins/ 目录,构建工具用 expo-module-scripts),用户就能在 app.json 的 plugins 里直接写库包名——目录结构与依赖组织见 docs /config-plugins/development-for-libraries.md。
7. 原生调试与 iOS 生命周期迁移
调试入口按平台分:
- Android:
npx expo run:android起原生构建;日志走adb logcat(或 Android Studio 里挂设备断点)。SDK 58 的expo-modules-core默认发预编译 .so(四种 ABI、debug/release 两套),空白工程全 ABI 干净构建从约 80 秒降到 39 秒(M3 Pro 实测数字);要改 core 源码调试时用 expo-build-properties 设android.buildExpoModulesCoreFromSource=true回到源码编译。 - iOS:
npx expo run:ios命令行构建失败时现在会打印 Xcode 构建日志路径(58 的 CLI 改进);断点调试则xed ios打开生成的工程,在 Xcode 里 Run——模块的 Swift 源码在 Pods 的 Development Pods 下就能看到。
模块作者与 iOS 生命周期打交道的正确方式不是改 AppDelegate,而是 AppDelegate subscribers:继承 ExpoAppDelegateSubscriber 的类写进 expo-module.config.json 的 apple.appDelegateSubscribers,pod install 后自动生成进 ExpoModulesProvider.swift;前提是整个 app 的 AppDelegate 继承 ExpoAppDelegate(Expo 模板本来就是)。
Warning(58 破坏性变更,自定义过 AppDelegate 的必读):iOS 27 SDK 强制 UIKit scene-based 生命周期,
npx expo prebuild现在会生成SceneDelegate.swift与Info.plist的UIApplicationSceneManifest条目,UIWindow改由 scene delegate 创建,不再是didFinishLaunchingWithOptions。changelog 原文口径:Expo 仍会把 URL、user activity、Handoff、生命周期与 quick action 事件转发给ExpoAppDelegate的 overrides 与 subscribers,基于这套机制的 config plugin 与模块照常工作;但手工管理ios/目录、bare RN 工程、或直接 override app delegate 方法的代码需要迁——官方迁移指南 https://expo.fyi/ios-scene-lifecycle 。expo-modules-core还新增了对 iPhone Duo 场景有用的几何辅助(如SceneGeometry.anchorPopover),Expo 包本身已不再读UIScreen.main。
8. 发布自己的模块包
决定发 npm(或私有 registry)时,流程与 3.2 的独立包一致:
cd my-module
npm run build # 编译 src/ TS,产出 build/
npm publish要点:package.json 的 main 指向编译产物;expo-module.config.json 声明 platforms(Apple 全家桶用 "apple" 而非 "ios",podspec 同步声明支持的 OS)与模块类名;native 目录要能随包发布(别在 .npmignore 里漏掉 ios/、android/)。用户侧 npx expo install your-module + dev build 即可,注册全程无感——这就是 autolinking 对生态的意义:库作者把"注册"这件事一次性消灭。monorepo 内部复用不用发 npm:包放一个目录,app 的 expo.autolinking.searchPaths 指过去(SDK 54 起必须显式列)。再往上是预编译模块(把 pod/aar 产物预构建给用户装,见 docs /guides/prebuilt-expo-modules),以及 @expo/ui 这种"View 全家桶"的组织形态——但先把 3.2 最后一句话想清楚:app 私有的东西用 inline module,跨项目分发的才值得发包。
实战建议
- 性能敏感或能力缺失才下沉原生。 JS 线程上的优化空间通常比想象中多;先确认瓶颈真在桥接,再动手写 module(桥接本身的固定开销在 58 已经被压得很低,普通业务调用根本不值得绕)。
- API 面越小越好。 一次调用传简单标量、返回值别嵌套大对象——这既是 58 调用优化的受益形状,也是 2.0 生成绑定的最优路径。把"一个模块干十件事"拆成"十个小模块"更接近 expo-* 官方包的组织方式。
- 2.0 beta 的介入点:新写的小模块(尤其 agent 生成的)适合直接试
@ExpoModule+@JS;存量 1.0 模块不迁也完全没问题,两套可共存。正式文档落地前,以 early-look 博客与expo-migrate-moduleskill 的行为为准。 - agent 时代,inline module 是增长最快的原生形态:一段 Swift/Kotlin 进
app/目录、dev build 即生效、没有发包仪式。把watchedDirectories想成一个受版本控制的私有 SDK,但记住它仍是实验特性——重要能力成熟后再考虑抽包。 - 守住 CNG 纪律:原生改动要么进
plugins/,要么进modules/,让npx expo prebuild --clean随时敢跑。做不到的那一刻,你就在手动维护一个 fork 了,升级成本从此自己扛。