Skip to content
应用配置与插件

应用配置与插件

Expo 项目里,app.json / app.config.ts 不只是"元数据文件"——它是你整个原生工程的唯一真相源。改一个字段,prebuild 就会把它编译进 iOS 的 Info.plist、Android 的 Manifest、图标资源、Gradle 配置里;config plugins 则是这套编译过程的扩展点。本章覆盖:静态与动态配置的分工、SDK 58 强制改写 NODE_ENV 这个升级必踩的行为变化、字段地图(对照 v58 官方参考逐一核实)、extra / EXPO_PUBLIC_* / EAS secrets 的边界,以及读懂和编写 config plugin。

基线:Expo SDK 58(2026-10-15 发布),事实核验时点 2026-10-20。

为什么 Expo 把配置当代码

传统双端原生项目里,“给 App 改名、换图标、加一条权限"是两次手工操作:Xcode 里点一遍,Android Studio 里再点一遍,还得祈祷两边行为一致。Expo 的思路是把这类变更收敛成一份声明式配置,需要原生工程时由 npx expo prebuild 现场生成——这就是第 01 章讲的 CNG(Continuous Native Generation)。

一个类比:android/ 和 ios/ 目录之于 app 配置,≈ dist/ 之于前端源码。构建产物随时可以从源码重新生成,所以不该手改、也通常不该提交进 git;app 配置就是那份"源码”。同一份配置,本地 expo prebuild 能用,EAS 云端构建也能用(见 06-EAS构建.md),两端产物从同一个定义推导出来。

官方文档(workflow/configuration,2026-07-28 更新)明确列了 app 配置的三个消费者:

  • npx expo prebuild 的原生工程生成;
  • Expo Go / development build 的项目加载(见 05-Expo-Go与开发构建.md);
  • OTA 更新清单(EAS Update 的 manifest,见 07-EAS发布与热更新.md)。

心智模型立住了,后面所有"为什么改了配置不生效"的问题都有统一答案:大部分 app 配置字段是构建期生效的,改完必须重新 prebuild + 重新构建原生应用,才会在设备上看到变化。

app.json 与 app.config.ts:静态与动态

两种形态,同一套 schema(完整字段参考 https://docs.expo.dev/versions/latest/config/app.md ):

  • 静态:app.json(或 app.config.json)。纯 JSON,不支持注释;官方文档特别指出静态配置可被 CLI 工具自动回写(典型如 eas init 把 extra.eas.projectId 写进去),动态配置则只能开发者手动维护。
  • 动态:app.config.js / app.config.ts。本质是 Node 代码,支持变量、函数、ESM import;不会被工具自动改写。app.config.mts / .cts / .mjs / .cjs 也会被识别,用于在 ESM/CJS 混用出问题时锁定模块格式。

解析规则按官方文档逐条核实过,理解成"中间件"最直观:

  1. 先读静态配置:有 app.config.json 用它,否则回落 app.json;两者都没有则从 package.json 和依赖推断默认值。
  2. 再找动态配置:app.config.ts 和 app.config.js 同时存在时,用 TS 版。
  3. 动态配置若导出函数,静态配置会以 ({ config }) => {} 的形参传进来,函数返回的对象才是最终配置。
  4. 最终配置不能包含 Promise;所有函数在 Expo 生态任何工具使用前被求值并序列化成 JSON。
  5. 最终对象若带顶层 expo: {} 键,则以它为准,根级其他键全部忽略。

实操上,超过 90% 的项目应该直接上 app.config.ts:JSON 不支持注释是第一个劝退理由,第二个是你要用到下面这些动态能力。VS Code 装官方的 Expo Tools 扩展可获得 app.json/app.config.ts 的全字段自动补全与 plugin props 校验。

最小可用配置长这样:

{
  "expo": {
    "name": "My app",
    "slug": "my-app"
  }
}

name 和 slug 是最小的必填集合(slug 是账号内 URL 唯一标识;owner 不写则取当前登录用户名)。

动态配置的典型用法

按环境变量切换名称 / bundle id——白标(white-label)或多环境最常见的写法。注意别用 NODE_ENV 做分支(下一节解释为什么 58 起它靠不住),自定义一个 APP_ENV:

import { ExpoConfig, ConfigContext } from 'expo/config';

const APP_ENV = process.env.APP_ENV ?? 'development';

const branding = {
  development: { name: 'MyApp Dev', bundleId: 'com.acme.myapp.dev' },
  staging: { name: 'MyApp Staging', bundleId: 'com.acme.myapp.staging' },
  production: { name: 'MyApp', bundleId: 'com.acme.myapp' },
}[APP_ENV];

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: branding.name,
  slug: 'my-app',
  scheme: 'myapp',
  ios: { ...config.ios, bundleIdentifier: branding.bundleId },
  android: { ...config.android, package: branding.bundleId },
  // 构建参数传给 app 运行时(非机密,见"extra vs EXPO_PUBLIC"一节)
  extra: {
    appEnv: APP_ENV,
    eas: { projectId: process.env.EAS_PROJECT_ID },
  },
});

CI 分支——本地和流水线的差异(如 CI 里跳过需要交互的设备配置):

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  plugins: [
    ...(config.plugins ?? []),
    // CI 上不开 devtools 类插件
    ...(process.env.CI ? [] : ['expo-dev-launcher']),
  ],
});

把构建号从 CI 变量注入——version 对应 iOS 的 CFBundleShortVersionString / Android 的 versionName,ios.buildNumber 与 android.versionCode 分别管构建号,EAS 可以自动递增(见 06-EAS构建.md),但自建 CI 时常这样写:

export default (): ExpoConfig => ({
  name: 'MyApp',
  slug: 'my-app',
  version: '2.4.0',
  ios: { bundleIdentifier: 'com.acme.myapp', buildNumber: process.env.BUILD_NUMBER ?? '1' },
  android: { package: 'com.acme.myapp', versionCode: Number(process.env.BUILD_NUMBER ?? '1') },
});

.env 家族与 SDK 58 的 NODE_ENV 强制行为

Expo CLI 会按当前"模式"自动加载 .env 文件,并用优先级从先到后合并(前面的文件优先):

模式 加载顺序(先 = 优先级高)
development .env.development.local → .env.local → .env.development → .env
production .env.production.local → .env.local → .env.production → .env

惯例:.env.local 放本机私货(比如局域网调试地址),.gitignore 里加一行 .env*.local。

SDK 58 破坏性行为变化:NODE_ENV 由 CLI 说了算

这是 58 升级最容易踩的坑,出自官方 changelog 的 “Notable breaking changes”(#48554、#48741、#49511 等)与环境变量指南(2026-10-12 更新):

注意:SDK 58 起,每个 Expo 命令会按自身语义把 NODE_ENV 强制设为 development 或 production 并覆盖你传入的任何值;不再加载 .env.staging、.env.test 这类非标准文件。官方例子:NODE_ENV=test npx expo start 现在加载的是 .env.development 而不是 .env.test;NODE_ENV=production npx expo start 同样是 development 模式。

各命令的模式判定(官方表格核实):

命令 模式
npx expo start development;带 --no-dev 时 production
npx expo export production;带 --dev 时 development
npx expo run:android debug / debugOptimized 变体 development,其余 production
npx expo run:ios 名字含 Debug 的 configuration development,其余 production(自定义的 Staging 等也算 production)
expo config / expo prebuild / expo-doctor 本地 development;EAS Build 上 production

派生影响:eas update 底层调 expo export,所以 NODE_ENV=test eas update 里的 test 同样会被覆盖成 production。官方给的理由是 NODE_ENV 已被太多工具(模块解析器、打包器、测试 runner)读取,RN 项目里不应再让它兼任 .env 文件切换器。

对策:

  • 区分环境用自定义变量(如上面的 APP_ENV),在 app.config.ts 里分支;或干脆拆成不同 EAS build profile(见 06-EAS构建.md)。
  • 用 EAS 的话,eas env:pull 可以把云端某环境的变量直接改写你本地的 .env.local,比依赖 NODE_ENV 可靠;不用 EAS 就写个小脚本按环境覆盖 .env.local。
  • 紧急排障的两个开关:EXPO_NO_DOTENV=1 禁止 CLI 加载 .env 进进程;EXPO_NO_CLIENT_ENV_VARS=1 禁止把 EXPO_PUBLIC_* 内联进 bundle。

字段地图(对照 v58 参考逐条核实)

下面只列高频字段和"58 时代该不该写"的判断;全量以 https://docs.expo.dev/versions/latest/config/app.md 为准(v58 文档站页面标题即 “app.json / app.config.js”,Reference v58.0.0)。

顶层常用项

  • name / slug:显示名 / 账号内唯一标识。
  • version:语义版本;配合 ios.buildNumber、android.versionCode。
  • scheme:深链前缀(myapp://)。只接受小写字母开头、后接小写字母/数字/+ . -(schema 有正则校验),可以是字符串或数组;ios.scheme / android.scheme 会与顶层合并。它是构建期配置,在 Expo Go 里无效。
  • orientation:default / portrait / landscape。
  • userInterfaceStyle:light / dark / automatic,默认 light;Android 上要装 expo-system-ui 才生效。
  • backgroundColor:根视图背景色,默认 #ffffff;iOS 生效同样依赖 expo-system-ui。
  • primaryColor:目前只在 Android 多任务卡片生效。
  • icon / splash:指向资源路径即可,展开在 08-SDK模块速查.md 的 expo-splash-screen 条目(SDK 58 模板就是把启动屏配成 "expo-splash-screen" 插件 props:backgroundColor / image / imageWidth);ios.icon 还支持 Xcode 的 .icon 文件格式和 light/dark/tinted 三态对象。
  • extra:任意对象,经 Constants.expoConfig.extra 在运行时读取(见下文边界讨论)。
  • platforms:不写默认 ["ios","android"],装了 react-dom 会自动含 web。
  • androidStatusBar:已标废弃,v58 参考明确写着改用 expo-status-bar 插件配置。

ios 段

  • bundleIdentifier:App Store 唯一 id;改 id 等于换 App,慎重。
  • supportsTablet(默认 false)/ isTabletOnly / requireFullScreen:后两者注意 iOS 27 的变化——changelog 明确 requireFullScreen 不再能阻止应用被缩放,ScreenOrientation.lockAsync 在可缩放窗口下可能无效(官方把 requireFullScreen 插件适配了,#48175)。
  • infoPlist:任意键直写 Info.plist,“Applied prior to all other Expo-specific configuration,不做校验”——原话,滥用有被 App Store 拒审风险。
  • entitlements:同上,直写 *.entitlements。
  • config:目前收录 usesNonExemptEncryption、googleMapsApiKey;此键会被过滤出 public 配置(不进 manifest)。
  • deploymentTarget(如 "18.6")、appleTeamId、privacyManifests(隐私清单 PrivacyInfo.xcprivacy)、associatedDomains(Universal Links,applinks:<域名> 格式)、usesAppleSignIn / usesIcloudStorage / accessesContactNotes 等开关。
  • 顺带一提:官方 v58 参考里没有 ios.includesAppAssets 这个字段,@expo/config-types@58.0.3 的 schema 里也搜不到——网上教程或 AI 生成配置里若出现它,直接删掉,CLI 会按未知字段给你一条校验警告。

android 段

  • package:Play 商店唯一包名,规则同上。
  • versionCode:整数递增。
  • adaptiveIcon:foregroundImage / backgroundImage / monochromeImage / backgroundColor,模板默认就配了四件套。
  • edgeToEdgeEnabled:这个字段已经不存在了。54 时代它是显式开关,55 的 schema 里只剩 edgeToEdgeEnabled?: true 的字面量残留,56 起连残留都没了(逐版拉 @expo/config-types 实测)。edge-to-edge 是 Android 15 的系统级强制 + RN 侧默认适配(演进线见 react-native/03-样式与布局.md),58 里它就是唯一形态,旧教程里写 "edgeToEdgeEnabled": false 一律无视。
  • permissions / blockedPermissions:前者添加、后者用 tools:node="remove" 移除依赖库 manifest 合并进来的权限。
  • intentFilters:App Links(autoVerify: true + https host 过滤)。
  • softwareKeyboardLayoutMode:resize / pan。
  • predictiveBackGestureEnabled:Android 16 预测性返回手势开关,SDK 58 模板默认显式写了 false。

plugins / updates / experiments

  • plugins:数组,元素三种形态 "name" / ["name"] / ["name", props],本章后半部分的主角。
  • updates:enabled(默认 true)、checkAutomatically(默认 ON_LOAD,可选 ON_ERROR_RECOVERY / WIFI_ONLY / NEVER)、url、fallbackToCacheTimeout、requestHeaders、codeSigningCertificate/codeSigningMetadata(会被过滤出 public 配置)、58 新增 maxUpdatesToKeep(默认 2,频繁 channel surfing 时调大,见 07-EAS发布与热更新.md)。
  • runtimeVersion:字符串(^[a-zA-Z\d][...]{0,254}$)、"exposdk:58.0.0" 形式、或对象 { policy: 'nativeVersion' | 'sdkVersion' | 'appVersion' | 'fingerprint' };ios.runtimeVersion / android.runtimeVersion 可按平台覆盖。它是"原生二进制与 OTA 更新的兼容性契约",机制细讲在 07 章,这里只需知道字段长什么样。
  • experiments:
    • typedRoutes:仍在 experiments 下,但默认模板已开箱 "typedRoutes": true(tarball 实测),新项目不必手动开。
    • reactCompiler:58 模板里同样默认开着,属实意外但属实实测。
    • tsconfigPaths:开启 tsconfig paths 别名解析,老项目常见。
    • noxcturnalTransformWorker:58 新实验项,changelog 里明写在 app config 的 experiments 设置,用 Rust transformer 替换 Babel 做冷启动打包提速(内部测试约 2 倍)。截至 2026-10-20 它尚未出现在 v58 配置参考页和 config-types schema 里——以 changelog 为准可用,但激活条件苛刻:项目不能有自定义 transform worker、不能带自定义 Babel 插件/presets,且跳过需要 Reanimated/Worklets 变换的文件。
    • experiments.buildCacheProvider:schema 里已标废弃,改用顶层 buildCacheProvider。
  • assetBundlePatterns:已弃用。58 schema 里字段还在但挂着 @deprecated,v58 文档参考页整页搜不到它。资源打包由 Metro 自动收集,更新资源的取舍改用 EAS Update 的 asset selection(updates.assetPatternsToBeBundled)。老项目里看到就删。
  • newArchEnabled:58 里不需要也不应该写。54 的 schema 还有它,55 起移除(逐版实测);新架构自 RN 0.76 起是唯一选项(见 react-native/01-核心概念与架构.md),SDK 58 默认模板的 app.json 也确实一个字节都没提它——这就是"默认已开"的最硬证据。

extra、EXPO_PUBLIC_* 与 EAS secrets:三者边界

这是高频事故点,先把分发路径讲清楚:

  • extra:属于 app 配置的一部分,会进最终 manifest / 打进产物。运行时经 expo-constants 的 Constants.expoConfig.extra 读取。任何跑到你 App 里的人都能拿到——不是机密通道。
  • EXPO_PUBLIC_*:构建期由 Metro 文本内联进 JS bundle。两条硬规矩(官方核实):必须写成静态点访问 process.env.EXPO_PUBLIC_KEY 才会被替换,process.env['EXPO_PUBLIC_KEY'] 或解构 const { EXPO_PUBLIC_X } = process.env 都不生效;node_modules 里的代码出于安全考虑不做替换。明文躺在编译产物里,同样不是机密通道。dev 模式下改 .env 需要整包 reload(shake → Reload)才能看到新值;SDK 58 修了一个陈年 bug——被内联的 EXPO_PUBLIC_* 值现在会记录进 transform 缓存(#47750),改值后旧值不再赖在 bundle 里。
  • EAS secrets / EAS 环境变量:只在构建机 / CI 进程里存在,永远不会进客户端产物。真正的机密(API 私钥、签名口令)放这里,见 06-EAS构建.md。

注意:三者唯一的判据是"攻击者拿反编译后的 App 能不能看到"。extra 和 EXPO_PUBLIC_* 的答案都是能。把它们当机密用是 Expo 事故排行榜常客——典型如把后端 admin key 写进 EXPO_PUBLIC_API_KEY 再发到公开 GitHub。唯一豁免场景:在 app.config.ts 里读取的构建期 secret(比如传给某插件的 API key),它不进 JS bundle,但会以插件写入的形态进原生工程(Info.plist/Gradle),依然谈不上机密。

想知道到底哪些东西被打进了产物:npx expo config --type public 打印的就是会内嵌进构建/更新、Constants.expoConfig 可读的内容。官方文档列了会被过滤掉的字段:ios.config、android.config、updates.codeSigningCertificate、updates.codeSigningMetadata。另外别在业务代码里直接 import config from '../app.json'——那会把整个文件打进去(包括没被过滤的敏感键),运行时永远走 Constants.expoConfig。

Config plugins:对原生工程的声明式补丁

是什么、处在哪个层次

config plugin 是注册在 plugins 数组里的 JS 函数,作用一句话:在 prebuild 生成原生工程时,替你把"默认 app 配置表达不了"的原生改动做掉——改 AndroidManifest、Info.plist、Gradle、Xcode 工程、Podfile.properties 等。它等价于"自动化你本来要在 Xcode/Android Studio 里手点的操作",因此只在 CNG + prebuild(本地或 EAS)的世界里工作;官方文档原话:“Plugins that add modifications can only be used with prebuilding and managed EAS Build”。

官方术语有四层,背下来 debugging 时看得懂堆栈:

withMyPlugin ("myPlugin")            [Config Plugin,plugins 数组里的入口]
→ withAndroidPlugin, withIosPlugin   [Plugin Function,按平台拆分]
→ withAndroidManifest, withInfoPlist [Mod Plugin Function,expo/config-plugins 提供的安全包装]
→ mods.android.manifest, mods.ios.infoplist  [Mod,真正落盘的修改器]

执行时机与顺序(核实过的说法)

两条不同的时间线,搞混是"我插件里写的东西怎么没生效"的根源:

  • 插件函数体在 app 配置求值阶段执行。expo config、expo start、prebuild——任何需要配置的命令都会跑到它。所以"修改配置本身"的逻辑(往 config.ios.infoPlist 塞键、改 plugins)要写在 mod 外面,否则非 prebuild 场景不会执行。
  • mod(mods.android.manifest 等这类回调)只在 prebuild 的 syncing 阶段执行,真正读文件-改-写盘。mods 对象不参与序列化,不会出现在 manifest 里,Updates.manifest 也拿不到。
  • 数组顺序即执行顺序:官方 plugins 指南原文——“Each plugin in the chain runs in the order it appears, with the output of one plugin becoming the input for the next”;plugins 数组底层就是用 withPlugins 串起来的。插件内部若同时包了 Android 与 iOS 修改,按你调用两个平台函数的顺序来。

排查生成结果的三板斧

# 最终配置全貌(静态+动态合并后)
npx expo config

# 会内嵌进产物/manifest 的那份(判断"什么被打包了")
npx expo config --type public

# 插件应用后、mods 未求值的配置;_internal.pluginHistory 在这里看
npx expo config --type prebuild

# introspection:不写盘,直接求值 mods,打印"prebuild 将生成的文件内容"
npx expo config --type introspect

# 本地重新生成原生工程验证(--clean 删旧目录重生成)
npx expo prebuild --clean --no-install

--type introspect 只支持"安全的"结构化 mod 这个子集:android.manifest、android.gradleProperties、android.strings、android.colors(含 colorsNight)、android.styles、ios.infoPlist、ios.entitlements、ios.expoPlist、ios.podfileProperties;字符串 regex 类和 ios.xcodeproj 不在其中。eas-cli 构建前同步 entitlements 就是靠 introspection 拿结果的。顺带一提:改了插件但只跑 expo config 看不到 mods 效果是正常的,mods 只在 prebuild 求值;VS Code Expo Tools 有 modifier 实时预览,比命令行舒服。

expo config --type prebuild 输出里的 _internal.pluginHistory 值得专门说一句:装了没写的官方模块(如 expo-camera)会被自动注入 legacy 插件,历史里版本是 UNVERSIONED 就意味着走的是 CLI 内置的无版本兜底插件——将来它的原生代码一变就可能在 build 时炸。稳妥做法是把用到的模块插件全部显式写进 plugins 数组,压掉自动注入。

官方模块插件的 props 用法

每个带原生配置的 SDK 模块都在自己文档页有 “Configurable properties” 表。常用四例:

{
  "expo": {
    "plugins": [
      ["expo-router", { "asyncRoutes": { "web": false } }],
      [
        "expo-notifications",
        {
          "icon": "./assets/notification-icon.png",
          "largeIcon": "./assets/notification-large-icon.png",
          "color": "#ffffff",
          "sounds": ["./assets/notification-sound.wav"],
          "enableRemoteNotifications": true,
          "enableBackgroundRemoteNotifications": false
        }
      ],
      [
        "expo-location",
        {
          "locationAlwaysAndWhenInUsePermission": "允许 $(PRODUCT_NAME) 使用你的位置",
          "isAndroidBackgroundLocationEnabled": true,
          "isAndroidForegroundServiceEnabled": true
        }
      ],
      [
        "expo-build-properties",
        {
          "android": {
            "enableMinifyInReleaseBuilds": true,
            "buildExpoModulesCoreFromSource": false,
            "compileSdkVersion": 37,
            "targetSdkVersion": 36
          },
          "ios": { "deploymentTarget": "16.4" }
        }
      ]
    ]
  }
}

几个要点(均对照文档/58 changelog 核实):

  • props 里的 $(PRODUCT_NAME) 是官方支持的插值。
  • expo-notifications:icon 必须是 96x96 全白透明 png(Google 规范,否则只显示成色块);largeIcon 是 SDK 58 新增的 Android 大图标属性;color 默认 #ffffff;enableRemoteNotifications=false 则不加 APNs entitlements,纯本地通知的 App 可以省掉一个审核面。
  • expo-location:iOS 那三个 *Permission 写的是权限弹窗文案(NS*UsageDescription);Android 后台定位靠 isAndroidBackgroundLocationEnabled,前台服务 isAndroidForegroundServiceEnabled 默认跟随后台开关。58 还有新的 expo-location/next API 和 iOS 地理围栏不再需要 background mode 的松绑(08-SDK模块速查.md 见)。
  • expo-build-properties 是"构建调音台",58 的两个关键项:android.enableMinifyInReleaseBuilds——58 的 Android 模板默认开 R8(gradle.properties 写死 =true,走 proguard-android-optimize.txt preset,体积和启动受益),依赖反射的老库可能缺 keep rules 崩给你看,退路是显式设 false;android.buildExpoModulesCoreFromSource——默认 false,即用上 58 新发的预编译 expo-modules-core .so(四 ABI、debug/release 全带,官方实测 Android 干净构建约砍半),只有你改了 core 的 C++ 源码才设 true。其余常用:buildArchs(本地调试只出 ["arm64-v8a"] 提速)、extraMavenRepos、ios.useFrameworks 等。
  • expo-router 插件:58 起 asyncRoutes 默认 { web: true }(web 路由代码分割,官方新默认);web.output 不写时 Router 项目默认 server(见 10-Web与托管.md);想要 57 的静态导出行为就设 "static" 并按需开新 prop apiRoutes(默认 server=true / static=false);unstable_useServerDataLoaders 等三个 unstable 开关 58 已标废弃且无效,升级时删掉。

写自己的插件

放哪里:官方示例就是项目根建 plugins/ 目录、按平台拆 withAndroidPlugin.ts / withIosPlugin.ts / withPlugin.ts,在 plugins 数组里用相对路径引用:["./plugins/withPlugin.ts", { ...props }]。写成 TS 是官方推荐(intellisense + props 校验),代价是要在 app.config.ts 顶部注册一次解析器:

import 'tsx/cjs'; // 让 Node 能 require plugins/ 下的 .ts

npm install --save-dev tsx 把它装进 devDependencies(官方文档同款步骤)。也可以不建文件,直接在动态配置里把函数塞进 plugins(序列化时函数名会替代本体,manifest 仍正常);或 import "./my-config-plugin" 走副作用注册。发布成 npm 包的插件走另一套解析:优先包根 app.plugin.js,其次 main 入口——库作者见官方 “Plugin development for libraries”。

三个高频 modifier 的真实小例(expo/config-plugins 从 expo 包 re-export,务必 from 'expo/config-plugins' 导入而不是直接依赖它,避免 hoisting 版本错配——官方明文的建议):

import { ConfigPlugin, withAndroidManifest } from 'expo/config-plugins';

// 给 AndroidManifest.xml 加一条 uses-permission。modResults 是 xml2js 解析出的
// manifest 对象,权限在根级 permissions 数组、`$` 是 XML 属性集合
export const withVibratePermission: ConfigPlugin = config =>
  withAndroidManifest(config, config => {
    config.modResults.permissions = [
      ...(config.modResults.permissions ?? []),
      { $: { 'android:name': 'android.permission.VIBRATE' } },
    ];
    return config;
  });
import { ConfigPlugin, withInfoPlist, withGradleProperties } from 'expo/config-plugins';

// Info.plist 写自定义键;props 必须是可 JSON 序列化的静态值(manifest 要求)
type Props = { appGroup?: string };

export const withCustomKeys: ConfigPlugin<Props> = (config, { appGroup } = {}) => {
  config = withInfoPlist(config, config => {
    if (appGroup) {
      config.modResults.ApplicationGroupIdentifiers = [appGroup];
    }
    return config;
  });
  // gradle.properties 是"官方推荐的静态修改面":Groovy 用 findProperty('expo.xxx') 读
  return withGradleProperties(config, config => {
    config.modResults.push({ type: 'property', key: 'expo.someSdk.enabled', value: 'true' });
    return config;
  });
};

withAppDelegate 单独说:它存在于 mod 列表里(按字符串修改 AppDelegate),但官方文档对它的态度是 “strongly discouraged”——regex 改源码的组合性差、模板一升级就碎、危险 mod 不保证幂等。给生命周期挂钩的正路是 AppDelegate subscribers(Expo Modules 的事件订阅机制,见 09-Expo-Modules与原生扩展.md);而且 SDK 58 的 iOS 模板因为 scene 生命周期改造,prebuild 会生成 SceneDelegate.swift、UIWindow 改由 scene delegate 创建——手改 AppDelegate 的旧插件在 58 上碎得更干脆。同理 Gradle 文件优先 gradle.properties、Podfile 优先 withPodfileProperties(写 Podfile.properties.json),实在躲不过才 withDangerousMod 上手(官方文档给的逃生舱,示例就是往 Podfile 插一行 pod)。

条件启用/禁用插件——在动态配置里按环境变量拼数组即可,withPlugins 负责把带 props 的形式串起来:

import { withPlugins } from 'expo/config-plugins';

const isDev = process.env.APP_ENV === 'development';

export default ({ config }: ConfigContext): ExpoConfig =>
  // withPlugins 按数组顺序链式执行,props 用 [plugin, props] 形式传入
  withPlugins(config, isDev ? [['expo-dev-launcher', {}]] : []);

写库插件时还有 createRunOncePlugin 防重复执行(对应 _internal.pluginHistory,用户同时手动+自动引入同一插件时只跑一次)。

插件出错怎么定位,分两个阶段看:

  • 配置求值阶段炸(函数体抛异常、props 类型不对):连 expo start 都会直接报错退出,栈里能看到插件文件路径——修 app.config/plugins 目录的导入链即可。
  • prebuild mods 阶段炸(文件解析失败、getMainApplicationOrThrow 抛了):只有 expo prebuild / run:ios / EAS Build 才复现。先 npx expo config --type introspect 看能不能出预期结果(能出来说明 mod 逻辑对,是模板/环境问题);EXPO_PROFILE=1 可测命令耗时,EXPO_DEBUG=1 出详细日志。EAS 云端日志里搜 “Config plugin” 前缀。
  • 反复调同一个插件记得 npx expo prebuild --clean:不 clean 时旧原生目录里的残留修改(尤其危险 mod 写过的文件)可能和新 mod 打架。

实战建议

改配置不生效的排查定式(按序走,很少跳出第 3 步):

  1. npx expo config 确认动态/静态合并结果就是你要的;
  2. npx expo config --type prebuild 确认插件在列(查 _internal.pluginHistory 有无 UNVERSIONED);
  3. 涉及原生字段(scheme、bundle id、权限、图标、插件改的 plist/manifest)→ npx expo prebuild --clean --no-install 检查生成文件,然后重建 dev build;只重 JS 是看不到变化的;
  4. 升级过 SDK 且手上有旧的 android/ ios/ 目录 → 删掉重新生成(官方升级指引明说,58 的 scene 生命周期改动让旧模板尤其该删)。

scheme 的三条军规:全小写(schema 正则硬约束);它是深链路由的第一段,大小写/连字符差一个就匹配不到(Router 侧配置见 04-Expo-Router.md);改 scheme 在已有原生目录时要同步替换 Info.plist 与 AndroidManifest 里的旧值,或者干脆 clean prebuild。

配置分层的生产模板——目录组织:

project/
├── app.config.ts          # 唯一的真相源:读 APP_ENV,拼 name/bundle id/插件清单
├── app.json               # 可选:只放不变的骨架,被 app.config.ts 的 ({config}) 接收
├── plugins/               # 本地插件,TS + tsx/cjs
│   ├── withCustomKeys.ts
│   └── withAnalyticsSdk.ts
├── .env                   # 提交:各环境共同的 EXPO_PUBLIC_* 默认值
├── .env.development       # 提交:dev 后端地址等
├── .env.production        # 提交
├── .env.local             # 不提交(.gitignore: .env*.local):本机密钥/代理地址
└── eas.json               # profile 里设 APP_ENV,衔接 06 章

app.config.ts 里"骨架 + 环境表"的可抄结构:

import 'tsx/cjs';
import { ExpoConfig, ConfigContext } from 'expo/config';

const APP_ENV = process.env.APP_ENV ?? 'development';
const isProd = APP_ENV === 'production';

const envs = {
  development: { name: 'MyApp Dev', id: 'com.acme.myapp.dev', api: 'https://dev-api.acme.com' },
  staging: { name: 'MyApp Staging', id: 'com.acme.myapp.staging', api: 'https://stg-api.acme.com' },
  production: { name: 'MyApp', id: 'com.acme.myapp', api: 'https://api.acme.com' },
} as const;
const env = envs[APP_ENV as keyof typeof envs];

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: env.name,
  slug: 'my-app',
  scheme: 'myapp',
  version: '2.4.0',
  orientation: 'portrait',
  userInterfaceStyle: 'automatic',
  ios: { bundleIdentifier: env.id, supportsTablet: false, config: { usesNonExemptEncryption: false } },
  android: {
    package: env.id,
    adaptiveIcon: { foregroundImage: './assets/android-fg.png', backgroundColor: '#E6F4FE' },
  },
  web: { output: 'server', favicon: './assets/favicon.png' },
  extra: { appEnv: APP_ENV, apiUrl: env.api, eas: config.extra?.eas },
  plugins: [
    'expo-router',
    ['expo-splash-screen', { backgroundColor: '#208AEF', image: './assets/splash.png', imageWidth: 76 }],
    ['expo-build-properties', { android: { buildArchs: isProd ? undefined : ['arm64-v8a'] } }],
    ['./plugins/withCustomKeys.ts', { appGroup: 'group.com.acme.myapp' }],
    ...(isProd ? [] : ['expo-dev-launcher']),
  ],
  experiments: { typedRoutes: true },
});

最后一条价值观:app 配置和插件写得越好,ios/、android/ 越可以不进 git——手改原生目录是配置债,prebuild 一次就蒸发;能用字段解决的不用插件,能用插件解决的不写危险 mod,能走 manifest 合并/gradle.properties/Podfile.properties 这些静态面的不碰 regex。这条升级路的收益在 11-升级与诊断.md 里兑现。