开发和调试插件

编辑

了解 Expo 配置插件的开发最佳实践和调试技术。


开发插件是扩展 Expo 生态系统的好方法。然而,有时你需要调试你的插件。本页面提供了一些开发和调试插件的最佳实践。

插件开发

使用 modifier previews 实时调试插件的结果。

为了简化插件开发,我们已将插件支持添加到 expo-module-scripts 中。 有关使用 TypeScript 和 Jest 构建插件的更多信息,请参考 config plugins guide

安装依赖

在提供配置插件的库中使用以下依赖:

package.json
{ "dependencies": {}, "devDependencies": { "expo": "^54.0.0" }, "peerDependencies": { "expo": ">=54.0.0" }, "peerDependenciesMeta": { "expo": { "optional": true } } }
  • 你可以更新 expo 的确切版本,以构建特定版本。
  • 对于依赖核心稳定 API 的简单配置插件,例如仅修改 AndroidManifest.xmlInfo.plist 的插件,可以使用如上示例中的松散依赖。
  • 你可能还想将 expo-module-scripts 安装为开发依赖,但这并不是必需的。

导入配置插件包

expo/config-pluginsexpo/config 包从 expo 包中重新导出。

const { ... } = require('expo/config-plugins'); const { ... } = require('expo/config');

通过 expo 包导入可以确保使用与 expo 包所依赖的 expo/config-pluginsexpo/config 包的版本。

如果不通过这种方式导入包,你可能意外地导入了不兼容的版本(这取决于开发人员所用的包管理器中的模块提升实现细节),或者根本无法导入模块(如果使用例如 Yarn Berry 或 pnpm 的“即插即用”功能)。

配置类型直接从 expo/config 导出,因此无需安装或从 expo/config-types 导入:

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

模块的最佳实践

  • 避免使用正则表达式:静态修改 是关键。如果你想修改 Android gradle 文件中的某个值,请考虑使用 gradle.properties。如果你想修改 Podfile 中的一些代码,请考虑写入 JSON 并让 Podfile 读取静态值。
  • 避免在模块中执行长期任务,比如发起网络请求或安装 Node 模块。
  • 不要在模块中添加交互式终端提示。
  • 仅在危险的模块中生成、移动和删除新文件。不这样做会破坏 自省
  • 利用内置的配置插件,如 withXcodeProject,以最小化文件读取和解析的次数。
  • 坚持使用预编译内部使用的 XML 解析库,这有助于防止不必要的代码重排。

插件结构与脚手架

版本控制

默认情况下,npx expo prebuild 对与项目使用的 Expo SDK 版本相关联的 源模板 运行转换。SDK 版本定义在 app.json 中,或从项目安装的 expo 的版本推断。

例如,当 Expo SDK 升级到新版本的 React Native 时,模板可能会进行重大更改,以考虑 React Native 的变化或 Android 或 iOS 的新版本。

如果你的插件主要使用 静态修改,那么它通常可以在 SDK 版本间良好工作。如果它使用正则表达式来转换应用代码,那么你确实需要记录你的插件适用的 Expo SDK 版本。在 SDK 发布周期中,有一个 beta 期间,你可以在新版本发布之前测试插件的工作情况。

插件属性

属性用于自定义插件在预构建期间的工作方式。它们必须始终是静态值(不支持函数或 Promise)。考虑以下类型:

type StaticValue = boolean | number | string | null | StaticArray | StaticObject; type StaticArray = StaticValue[]; interface StaticObject { [key: string]: StaticValue | undefined; }

静态属性是必需的,因为应用配置必须可序列化为 JSON,以用作应用清单。

如果可能,尽量让你的插件在没有 props 的情况下工作,这将帮助解析工具,如 expo installVS Code Expo Tools 更好地工作。请记住,每个你添加的属性都会增加复杂性,使将来更难以更改,并增加需要测试的功能数量。良好的默认值优于必要的配置(如果可行)。

开发环境

工具

我们强烈建议安装 Expo Tools VS Code extension,因为这将对插件进行自动验证,并提供错误信息以及其他配置插件开发的生活质量改进。

设置一个游乐场环境

你可以使用 JS 轻松开发插件,但如果你想设置 Jest 测试并使用 TypeScript,则需要一个单一代码仓库(monorepo)。

单一代码仓库使你能够像在 npm 中发布一样,在应用配置中导入一个 Node 模块。Expo 配置插件内置完全支持单一代码仓库,因此你所需要做的就是设置一个项目。

在你单一代码仓库的 packages/ 目录中,创建一个模块,并在其中 引导配置插件

手动运行插件

如果你不熟悉设置单一代码仓库,你可以尝试手动运行插件:

  • 在具有配置插件的包中运行 npm pack
  • 在你的测试项目中运行 npm install path/to/react-native-my-package-1.0.0.tgz,这将把包添加到你的 package.json dependencies 对象中。
  • 将包添加到你的 app.json 中的 plugins 数组: { "plugins": ["react-native-my-package"] }
  • 如果你需要更新包,更改包的 package.json 中的 version 并重复该过程。

使用插件修改本机文件

修改 AndroidManifest.xml

包应该尝试在使用配置插件之前使用内置的 AndroidManifest.xml 合并系统。这可以用于静态的、非可选的特性,如权限。这将确保特性在构建时合并,而不是在预构建时,从而减少由于用户忘记预构建而遗漏配置的可能性。缺点是用户无法使用 自省 来预览更改和调试潜在问题。

这是一个包的 AndroidManifest.xml 示例,注入了所需的权限:

AndroidManifest.xml
<manifest package="expo.modules.filesystem" xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.INTERNET"/> </manifest>

如果你正在为本地项目构建插件,或者如果你的包需要更多的控制,那么你应该实现一个插件。

你可以使用内置的类型和帮助程序来简化处理复杂对象的过程。 这是一个将 <meta-data android:name="..." android:value="..."/> 添加到默认 <application android:name=".MainApplication" /> 的示例。

my-config-plugin.ts
import { AndroidConfig, ConfigPlugin, withAndroidManifest } from 'expo/config-plugins'; import { ExpoConfig } from 'expo/config'; // 使用帮助程序使错误消息统一,并帮助减少 XML 格式更改。 const { addMetaDataItemToMainApplication, getMainApplicationOrThrow } = AndroidConfig.Manifest; export const withMyCustomConfig: ConfigPlugin = config => { return withAndroidManifest(config, async config => { // 修改器可以是异步的,但尽量保持快速。 config.modResults = await setCustomConfigAsync(config, config.modResults); return config; }); }; // 将此函数从修改中分离出来,可以更轻松地进行测试。 async function setCustomConfigAsync( config: Pick<ExpoConfig, 'android'>, androidManifest: AndroidConfig.Manifest.AndroidManifest ): Promise<AndroidConfig.Manifest.AndroidManifest> { const appId = 'my-app-id'; // 获取 <application /> 标签,并在不存在时断言它。 const mainApplication = getMainApplicationOrThrow(androidManifest); addMetaDataItemToMainApplication( mainApplication, // `android:name` 的值 'my-app-id-key', // `android:value` 的值 appId ); return androidManifest; }

修改 Info.plist

使用 withInfoPlist 比在 app.json 中静态修改 expo.ios.infoPlist 对象更安全,因为它读取 Info.plist 的内容并将其与 expo.ios.infoPlist 合并,这意味着你可以尝试保持你的更改不被覆盖。

这是一个将 GADApplicationIdentifier 添加到 Info.plist 的示例:

my-config-plugin.ts
import { ConfigPlugin, withInfoPlist } from 'expo/config-plugins'; // 传递 `<string>` 以指定此插件需要一个字符串属性。 export const withCustomConfig: ConfigPlugin<string> = (config, id) => { return withInfoPlist(config, config => { config.modResults.GADApplicationIdentifier = id; return config; }); };

修改 iOS Podfile

iOS Podfile 是 CocoaPods 的配置文件,是 iOS 的依赖管理器。它类似于 iOS 的 package.jsonPodfile 是一个 Ruby 文件,这意味着你 不能 安全地从 Expo 配置插件中修改它,而应该选择其他方法,例如 Expo Autolinking 钩子。

我们确实提供了一种与 Podfile 安全交互的机制,但它非常有限。 版本控制的 模板 Podfile 是硬编码的,从静态 JSON 文件 Podfile.properties.json 中读取,我们公开了一个 mod(ios.podfileProperties, withPodfileProperties)以安全地读取和写入此文件。 这用于 expo-build-properties 和配置 JavaScript 引擎。

将插件添加到 pluginHistory

_internal.pluginHistory 被创建以防止在从遗留的 UNVERSIONED 插件迁移到版本控制插件时重复执行插件。

my-config-plugin.ts
import { ConfigPlugin, createRunOncePlugin } from 'expo/config-plugins'; // 保持名称和版本与其包同步。 const pkg = require('my-cool-plugin/package.json'); const withMyCoolPlugin: ConfigPlugin = config => config; // 一个帮助方法,包装 `withRunOnce` 并将项目附加到 `pluginHistory`。 export default createRunOncePlugin( // 要保护的插件。 withMyCoolPlugin, // 用于跟踪插件是否已运行的标识符。 pkg.name, // 可选版本属性,如果省略,默认为 UNVERSIONED。 pkg.version );

配置 Android 应用启动

你可能会发现你的项目需要在 JS 引擎启动之前设置配置。 例如,在 Android 的 expo-splash-screen 中,我们需要在 MainActivity.javaonCreate 方法中指定缩放模式。 我们不尝试通过危险的修改将这些更改注入到 MainActivity 中,而是使用生命周期钩子和静态设置的系统 安全地确保特性在所有支持的 Android 语言(Java、Kotlin)、Expo 版本和配置插件组合中有效。

该系统由三个组件组成:

  • ReactActivityLifecycleListeners: 一个接口,由 expo-modules-core 提供,用于在项目的 ReactActivityonCreate 方法被调用时获取本机回调。
  • withStringsXml: 一个由 expo/config-plugins 提供的修改器,它将一个属性写入 Android strings.xml 文件,库可以安全地读取 strings.xml 值并做初始设置。字符串 XML 值遵循指定格式以保持一致性。
  • SingletonModule(可选):一个由 expo-modules-core 提供的接口,用于在本机模块和 ReactActivityLifecycleListeners 之间创建共享接口。

考虑这个例子:我们希望在 onCreate 方法被调用后,直接将一个自定义的“值”字符串设置为 Android Activity 的一个属性。 我们可以通过创建 Node 模块 expo-custom,实现 expo-modules-core 和 Expo 配置插件来安全地完成这一点:

首先,我们在 Android 本机模块中注册 ReactActivity 监听器,只有在用户在其项目中有 expo-modules-core 支持时,这将被调用(在使用 Expo CLI、Create React Native App、Ignite CLI 和 Expo 预构建引导的项目中默认设置)。

expo-custom/android/src/main/java/expo/modules/custom/CustomPackage.kt
package expo.modules.custom import android.content.Context import expo.modules.core.BasePackage import expo.modules.core.interfaces.ReactActivityLifecycleListener class CustomPackage : BasePackage() { override fun createReactActivityLifecycleListeners(activityContext: Context): List<ReactActivityLifecycleListener> { return listOf(CustomReactActivityLifecycleListener(activityContext)) } // ... }

接下来,我们实现 ReactActivity 监听器,这个监听器接受 Context,并能够从项目 strings.xml 文件中读取。

expo-custom/android/src/main/java/expo/modules/custom/CustomReactActivityLifecycleListener.kt
package expo.modules.custom import android.app.Activity import android.content.Context import android.os.Bundle import android.util.Log import expo.modules.core.interfaces.ReactActivityLifecycleListener class CustomReactActivityLifecycleListener(activityContext: Context) : ReactActivityLifecycleListener { override fun onCreate(activity: Activity, savedInstanceState: Bundle?) { // 在 JS 引擎启动之前执行静态任务。 // 这些值是通过配置插件定义的。 var value = getValue(activity) if (value != "") { // 做一些需要静态值的 Activity 处理... } } // 命名是节点模块名(`expo-custom`)加上值名(`value`)使用下划线作为分隔符 // 例如 `expo_custom_value` // `@expo/vector-icons` + `iconName` -> `expo__vector_icons_icon_name` private fun getValue(context: Context): String = context.getString(R.string.expo_custom_value).toLowerCase() }

我们必须定义默认的 string.xml 值,用户可以通过在其 strings.xml 文件中使用相同的 name 属性进行本地覆盖。

expo-custom/android/src/main/res/values/strings.xml
<?xml version="1.0" encoding="utf-8"?> <resources> <string name="expo_custom_value" translatable="false"></string> </resources>

此时,裸用户可以通过在其本地 strings.xml 文件中创建字符串来配置此值(假设他们也设置了 expo-modules-core 支持):

./android/app/src/main/res/values/strings.xml
<?xml version="1.0" encoding="utf-8"?> <resources> <string name="expo_custom_value" translatable="false">我爱 Expo</string> </resources>

对于托管用户,我们可以通过 Expo 配置插件(安全地)公开此功能:

expo-custom/app.plugin.js
const { AndroidConfig, withStringsXml } = require('expo/config-plugins'); function withCustom(config, value) { return withStringsXml(config, config => { config.modResults = setStrings(config.modResults, value); return config; }); } function setStrings(strings, value) { // 帮助程序,用于添加 string.xml JSON 项目或使用相同名称覆盖现有项目。 return AndroidConfig.Strings.setStringItem( [ // 表示为 JSON 的 XML // <string name="expo_custom_value" translatable="false">值</string> { $: { name: 'expo_custom_value', translatable: 'false' }, _: value }, ], strings ); }

托管 Expo 用户现在可以像这样使用此 API:

app.json
{ "expo": { "plugins": [["expo-custom", "I Love Expo"]] } }

通过重新运行 npx expo prebuild -peas build -p android,或 npx expo run:ios),用户现在可以看到在其托管项目中安全应用的更改!

从示例中可以看出,我们在与应用代码(expo-modules-core)交互时高度依赖应用代码(本机项目)。这确保了我们的配置插件安全可靠,希望能够长久使用!

调试配置插件

你可以通过运行 EXPO_DEBUG=1 expo prebuild 来调试配置插件。如果启用 EXPO_DEBUG,插件堆栈日志将被打印,这对于查看运行了哪些修改以及它们运行的顺序非常有用。要查看所有静态插件解析错误,请启用 EXPO_CONFIG_PLUGIN_VERBOSE_ERRORS,这通常只有在插件作者需要时才需要。默认情况下,某些自动插件错误是隐藏的,因为它们通常与版本问题有关,并不是很有帮助(也就是说,遗留包尚未具备配置插件)。

运行 npx expo prebuild --clean 将在编译之前删除生成的本机目录。

你还可以运行 npx expo config --type prebuild 打印插件的结果,未评估的 mods(不会生成代码)。

Expo CLI 命令可以使用 EXPO_PROFILE=1 进行性能分析。

自省

自省是一种先进的技术,用于读取修饰符的评估结果,而不生成项目中的任何代码。 这可以用于快速调试 静态修改 的结果,而无需运行预构建。 你可以通过使用 vscode-expo预览特性 实时与自省交互。

你可以尝试通过在项目中运行 expo config --type introspect 进行自省。

自省仅支持一部分修改器:

  • android.manifest
  • android.gradleProperties
  • android.strings
  • android.colors
  • android.colorsNight
  • android.styles
  • ios.infoPlist
  • ios.entitlements
  • ios.expoPlist
  • ios.podfileProperties

自省仅适用于安全的修饰符(静态文件,如 JSON、XML、plist、properties),除了 ios.xcodeproj,因为它通常需要文件系统更改,使其不可重复执行。

自省通过创建与默认基础方法类似的自定义基础方法工作,只是它们在最后不会将 modResults 写入磁盘。 它们并不持久,而是将结果保存到应用配置中的 _internal.modResults 下,后面跟着 mod 的名称,例如 ios.infoPlist mod 保存到 _internal.modResults.ios.infoPlist: {}

作为实例,自省被 eas-cli 用于确定托管应用中最终的 iOS 权限,以便在构建之前能够与 Apple 开发者门户同步。自省也可作为便利的调试和开发工具使用。

遗留插件

为了使 eas build 的工作方式与经典的 expo build 服务相同,我们添加了对 “遗留插件”的支持,这些插件在项目中安装后会自动应用。

例如,假设项目中安装了 expo-camera,但在其 app.json 中没有 plugins: ['expo-camera']。 Expo CLI 会自动将 expo-camera 添加到插件中,以确保所需的相机和麦克风权限被添加到项目中。 用户仍然可以通过手动将其添加到 plugins 数组来定制 expo-camera 插件,手动定义的插件将优先于自动插件。

你可以通过运行 expo config --type prebuild 和查看 _internal.pluginHistory 属性来调试添加了哪些插件。

这将显示一个对象,其中包含使用来自 expo/config-pluginswithRunOnce 添加的所有插件。

注意,expo-location 使用 version: '11.0.0',而 react-native-maps 使用 version: 'UNVERSIONED'。这意味着:

  • expo-locationreact-native-maps 都已安装在项目中。
  • expo-location 正在使用来自项目的 node_modules/expo-location/app.plugin.js 的插件。
  • 安装在项目中的 react-native-maps 的版本没有插件,因此它退回到随 expo-cli 发货的未经版本控制的插件,以支持遗留功能。
{ _internal: { pluginHistory: { 'expo-location': { name: 'expo-location', version: '11.0.0', }, 'react-native-maps': { name: 'react-native-maps', version: 'UNVERSIONED', }, }, }, };

为了获得最 稳定 的体验,你应该尝试让项目中没有 UNVERSIONED 插件。这是因为 UNVERSIONED 插件可能不支持项目中的本机代码。 例如,假设你在项目中有一个 UNVERSIONED Facebook 插件,如果 Facebook 本机代码或插件发生重大更改,会中断项目预构建的方式,导致构建错误。

静态修改

插件可以使用正则表达式转换应用代码,但如果模板随时间而变化,这些修改是危险的,那么正则表达式就变得难以预测(类似于用户手动修改文件或使用自定义模板)。以下是一些不应手动修改的文件示例,以及替代方案。

Android Gradle 文件

Gradle 文件使用 Groovy 或 Kotlin 编写。它们用于管理依赖、版本控制和 Android 应用中的其他设置。 不要使用 withProjectBuildGradlewithAppBuildGradlewithSettingsGradle 方法直接修改它们,而是利用静态的 gradle.properties 文件。

gradle.properties 是静态的键/值对,Groovy 文件可以读取。例如,假设你想控制 Groovy 中的某个开关:

gradle.properties
expo.react.jsEngine=hermes

然后在 Gradle 文件中:

app/build.gradle
project.ext.react = [enableHermes: findProperty('expo.react.jsEngine') ?: 'jsc']
  • 对于 gradle.properties 中的键,使用以 . 分隔的 camel case,通常以 expo 前缀开头,以表示该属性由预构建管理。
  • 要访问属性,使用两种全局方法之一:
    • property:获取属性,如果未定义则抛出错误。
    • findProperty:在属性缺失时不抛出错误获取属性。这通常可以与 ?: 操作符一同使用,以提供默认值。

通常,你应仅通过 Expo Auto-linking 与 Gradle 文件交互,这提供了与项目文件的编程接口。

iOS AppDelegate

某些模块可能需要将代理方法添加到项目 AppDelegate。这可以通过使用 AppDelegate subscribers 安全完成,或者通过 withAppDelegate 方法冒险执行(强烈不推荐)。 使用 AppDelegate 订阅者可以让本机 Expo 模块以安全可靠的方式响应重要事件。

以下是 AppDelegate 订阅者作用的示例。此外,你会在 GitHub 上的社区仓库中找到许多示例(其中一个示例)。

iOS CocoaPods Podfile

Podfile 可以通过正则表达式进行自定义(这被认为是危险的,因为这些类型的更改不易组合,多次更改可能会发生冲突),但更可靠的方法是在一个名为 Podfile.properties.json 的 JSON 文件中设置配置值。请查看以下如何使用 podfile_properties 自定义 Podfile

Podfile
require 'json' podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {} platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1' target 'yolo27' do use_expo_modules! # ... # podfile_properties['your_property'] end

通常,你应仅通过 Expo Auto-linking 与 Podfile 交互,这提供了与项目文件的编程接口。

自定义基础修改器

Expo CLI npx expo prebuild 命令使用 @expo/prebuild-config 来获取默认基础修饰符。这些默认值仅管理一小部分常见文件,如果你想管理自定义文件,可以通过添加新的基础修饰符在本地实现。

例如,假设你想要增加对管理 ios/*/AppDelegate.h 文件的支持,你可以通过添加一个 ios.appDelegateHeader 修改器来完成。

这个示例使用 tsx 来便于简单的本地 TypeScript 支持,这并不是严格必要的。 了解更多

withAppDelegateHeaderBaseMod.ts
import { ConfigPlugin, IOSConfig, Mod, withMod, BaseMods } from 'expo/config-plugins'; import fs from 'fs'; /** * 一个插件,向预构建配置添加新的基础修改器。 */ export function withAppDelegateHeaderBaseMod(config) { return BaseMods.withGeneratedBaseMods<'appDelegateHeader'>(config, { platform: 'ios', providers: { // 附加一个自定义规则,以向 `mods.ios.appDelegateHeader` 提供 AppDelegate 头文件数据 appDelegateHeader: BaseMods.provider<IOSConfig.Paths.AppDelegateProjectFile>({ // 获取应传递给 `read` 方法的本地文件路径。 getFilePath({ modRequest: { projectRoot } }) { const filePath = IOSConfig.Paths.getAppDelegateFilePath(projectRoot); // 将 .m 替换为 .h if (filePath.endsWith('.m')) { return filePath.substr(0, filePath.lastIndexOf('.')) + '.h'; } // 可能是一个 Swift 项目... throw new Error(`Could not locate a valid AppDelegate.h at root: "${projectRoot}"`); }, // 从文件系统读取输入文件。 async read(filePath) { return IOSConfig.Paths.getFileInfo(filePath); }, // 将结果输出到文件系统。 async write(filePath: string, { modResults: { contents } }) { await fs.promises.writeFile(filePath, contents); }, }), }, }); } /** * (工具)提供用于修改的 AppDelegate 头文件。 */ export const withAppDelegateHeader: ConfigPlugin<Mod<IOSConfig.Paths.AppDelegateProjectFile>> = ( config, action ) => { return withMod(config, { platform: 'ios', mod: 'appDelegateHeader', action, }); }; // (示例)记录修改器的内容。 export const withSimpleAppDelegateHeaderMod = config => { return withAppDelegateHeader(config, config => { console.log('modify header:', config.modResults); return config; }); };

要使用这个新的基础 mod,请将其添加到插件数组中。基础 mod MUST 最后添加在所有使用该 mod 的其他插件之后,原因是它必须在过程结束时将结果写入磁盘。

app.config.js
// 用于使用 TS 的外部文件 require('tsx/cjs'); import { withAppDelegateHeaderBaseMod, withSimpleAppDelegateHeaderMod, } from './withAppDelegateHeaderBaseMod.ts'; export default ({ config }) => { if (!config.plugins) config.plugins = []; config.plugins.push( withSimpleAppDelegateHeaderMod, // 基础 mods 必须最后 withAppDelegateHeaderBaseMod ); return config; };

有关更多信息,请参见 添加支持的 PR

expo install

当使用 npx expo install 命令安装节点模块时,如果它包含配置插件,它将自动添加到项目的应用配置中。这简化了设置并帮助防止用户忘记添加插件。然而,这确实带来了一些注意事项:

  1. npx expo install 仅将使用根 app.config.js 文件的配置插件自动添加到应用清单中。此规则是为了防止像 lodash 这样的流行包被误认为是配置插件而导致预构建失败。
  2. 当前没有检测配置插件是否具有强制属性的机制。因此,expo install 将仅添加插件,而不尝试添加任何额外的属性。例如,expo-camera 具有可选的额外属性,因此 plugins: ['expo-camera'] 是有效的,但如果它有强制属性,那么 expo-camera 将引发错误。
  3. 只有当用户的项目使用静态应用配置(app.jsonapp.config.json)时,插件才可以自动添加。如果用户在使用 app.config.js 的项目中运行 expo install expo-camera,他们将看到如下警告:
无法自动写入动态配置:app.config.js 请将以下内容添加到你的应用配置中 { "plugins": [ "expo-camera" ] }