开发和调试插件
编辑
了解 Expo 配置插件的开发最佳实践和调试技术。
开发插件是扩展 Expo 生态系统的好方法。然而,有时你需要调试你的插件。本页面提供了一些开发和调试插件的最佳实践。
插件开发
使用 modifier previews 实时调试插件的结果。
为了简化插件开发,我们已将插件支持添加到 expo-module-scripts 中。
有关使用 TypeScript 和 Jest 构建插件的更多信息,请参考 config plugins guide。
安装依赖
在提供配置插件的库中使用以下依赖:
{ "dependencies": {}, "devDependencies": { "expo": "^54.0.0" }, "peerDependencies": { "expo": ">=54.0.0" }, "peerDependenciesMeta": { "expo": { "optional": true } } }
- 你可以更新
expo的确切版本,以构建特定版本。 - 对于依赖核心稳定 API 的简单配置插件,例如仅修改 AndroidManifest.xml 或 Info.plist 的插件,可以使用如上示例中的松散依赖。
- 你可能还想将
expo-module-scripts安装为开发依赖,但这并不是必需的。
导入配置插件包
expo/config-plugins 和 expo/config 包从 expo 包中重新导出。
const { ... } = require('expo/config-plugins'); const { ... } = require('expo/config');
通过 expo 包导入可以确保使用与 expo 包所依赖的 expo/config-plugins 和 expo/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 install 或 VS 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.jsondependencies对象中。 - 将包添加到你的 app.json 中的
plugins数组:{ "plugins": ["react-native-my-package"] }- 如果你已经安装 VS Code Expo Tools,自动完成功能应该能正常工作。
- 如果你需要更新包,更改包的 package.json 中的
version并重复该过程。
使用插件修改本机文件
修改 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" /> 的示例。
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 的示例:
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.json。 Podfile 是一个 Ruby 文件,这意味着你 不能 安全地从 Expo 配置插件中修改它,而应该选择其他方法,例如 Expo Autolinking 钩子。
我们确实提供了一种与 Podfile 安全交互的机制,但它非常有限。
版本控制的 模板 Podfile 是硬编码的,从静态 JSON 文件 Podfile.properties.json 中读取,我们公开了一个 mod(ios.podfileProperties, withPodfileProperties)以安全地读取和写入此文件。
这用于 expo-build-properties 和配置 JavaScript 引擎。
将插件添加到 pluginHistory
_internal.pluginHistory 被创建以防止在从遗留的 UNVERSIONED 插件迁移到版本控制插件时重复执行插件。
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.java 的 onCreate 方法中指定缩放模式。
我们不尝试通过危险的修改将这些更改注入到 MainActivity 中,而是使用生命周期钩子和静态设置的系统
安全地确保特性在所有支持的 Android 语言(Java、Kotlin)、Expo 版本和配置插件组合中有效。
该系统由三个组件组成:
ReactActivityLifecycleListeners: 一个接口,由expo-modules-core提供,用于在项目的ReactActivity的onCreate方法被调用时获取本机回调。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 预构建引导的项目中默认设置)。
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 文件中读取。
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 属性进行本地覆盖。
<?xml version="1.0" encoding="utf-8"?> <resources> <string name="expo_custom_value" translatable="false"></string> </resources>
此时,裸用户可以通过在其本地 strings.xml 文件中创建字符串来配置此值(假设他们也设置了 expo-modules-core 支持):
<?xml version="1.0" encoding="utf-8"?> <resources> <string name="expo_custom_value" translatable="false">我爱 Expo</string> </resources>
对于托管用户,我们可以通过 Expo 配置插件(安全地)公开此功能:
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:
{ "expo": { "plugins": [["expo-custom", "I Love Expo"]] } }
通过重新运行 npx expo prebuild -p(eas 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.manifestandroid.gradlePropertiesandroid.stringsandroid.colorsandroid.colorsNightandroid.stylesios.infoPlistios.entitlementsios.expoPlistios.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-plugins 的 withRunOnce 添加的所有插件。
注意,expo-location 使用 version: '11.0.0',而 react-native-maps 使用 version: 'UNVERSIONED'。这意味着:
expo-location和react-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 应用中的其他设置。
不要使用 withProjectBuildGradle、withAppBuildGradle 或 withSettingsGradle 方法直接修改它们,而是利用静态的 gradle.properties 文件。
gradle.properties 是静态的键/值对,Groovy 文件可以读取。例如,假设你想控制 Groovy 中的某个开关:
expo.react.jsEngine=hermes
然后在 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 上的社区仓库中找到许多示例(其中一个示例)。
expo-linking: LinkingAppDelegateSubscriber.swift (openURL)expo-notifications: NotificationsAppDelegateSubscriber.swift (didRegisterForRemoteNotificationsWithDeviceToken, didFailToRegisterForRemoteNotificationsWithError, didReceiveRemoteNotification)
iOS CocoaPods Podfile
Podfile 可以通过正则表达式进行自定义(这被认为是危险的,因为这些类型的更改不易组合,多次更改可能会发生冲突),但更可靠的方法是在一个名为 Podfile.properties.json 的 JSON 文件中设置配置值。请查看以下如何使用 podfile_properties 自定义 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 支持,这并不是严格必要的。 了解更多。
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 的其他插件之后,原因是它必须在过程结束时将结果写入磁盘。
// 用于使用 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 命令安装节点模块时,如果它包含配置插件,它将自动添加到项目的应用配置中。这简化了设置并帮助防止用户忘记添加插件。然而,这确实带来了一些注意事项:
npx expo install仅将使用根 app.config.js 文件的配置插件自动添加到应用清单中。此规则是为了防止像lodash这样的流行包被误认为是配置插件而导致预构建失败。- 当前没有检测配置插件是否具有强制属性的机制。因此,
expo install将仅添加插件,而不尝试添加任何额外的属性。例如,expo-camera具有可选的额外属性,因此plugins: ['expo-camera']是有效的,但如果它有强制属性,那么expo-camera将引发错误。 - 只有当用户的项目使用静态应用配置(app.json 和 app.config.json)时,插件才可以自动添加。如果用户在使用 app.config.js 的项目中运行
expo install expo-camera,他们将看到如下警告:
无法自动写入动态配置:app.config.js 请将以下内容添加到你的应用配置中 { "plugins": [ "expo-camera" ] }