关于Expo插件,以及原生能力

关于Expo插件,以及原生能力

Aron Lv3

首先呢,这篇博文的所有知识点都能在Expo Modules文档中找到,我这里主要是把自己对expo模块的理解写出来,也省的以后在同样的知识点上浪费时间。
这篇文章同样回答了很多人的一个问题,那就是Expo到底有没有原生能力?能不能调用或者修改原声代码?本文介绍了Expo使用原生代码的其中一种方式,封装原生代码为Expo模块,发布到npm,方便自己,也方便了有同样需求的其他开发者,岂不美哉!

创建模块

命令行使用npx create-expo-module 你的expo模块名,比如我希望创建一个用于监听硬件键盘输入的插件,那么我可以:

1
npx create-expo-module expo-keyboard-event

执行这个命令后,接下来根据提示输入插件的基本信息,比如插件描述,你的GitHub地址等等,如下所示:

1
2
3
4
5
6
7
8
9
➜  Desktop npx create-expo-module expo-keyboard-event
✔ What is the name of the npm package? … expo-keyboard-event # npm包名
✔ What is the native module name? … ExpoKeyboardEvent # 原生模块类型名,对应iOS以及安卓原生项目中的文件名和类名
✔ How would you describe the module? … Expo原生模块插件,用于监听硬件键盘。 # 插件介绍
✔ What is the Android package name? … expo.modules.keyboardevent # 安卓软件包名
✔ What is the name of the package author? … Aron # 作者名字
✔ What is the email address of the author? … Random1996@163.com # 作者邮箱
✔ What is the URL to the author's GitHub profile? … https://github.com/likeSo # 作者G站地址
✔ What is the URL for the repository? … https://github.com/likeSo/expo-keyboard-event # 插件仓库地址

输入完了之后,Expo会开始安装依赖,接下来开始介绍目录结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
├── CHANGELOG.md # 你的插件更新日志
├── README.md # 你的插件介绍
├── android # 安卓原生代码目录
│   ├── build.gradle
│   └── src
│   └── main
│   ├── AndroidManifest.xml
│   └── java
│   └── expo
│   └── modules
│   └── keyboardevent
│   ├── ExpoKeyboardEventModule.kt # 原生安卓代码类
│   └── ExpoKeyboardEventView.kt # 原生安卓页面类
├── example # 案例目录
│   ├── App.tsx
│   ├── babel.config.js
│   ├── metro.config.js
│   ├── tsconfig.json
│   └── webpack.config.js
├── expo-module.config.json
├── ios # iOS原生代码目录
│   ├── ExpoKeyboardEvent.podspec
│   ├── ExpoKeyboardEventModule.swift # 原生swift代码类
│   └── ExpoKeyboardEventView.swift # 原生swift页面类
├── package.json # 插件依赖
├── src # 插件实际代码文件目录
│   ├── ExpoKeyboardEvent.types.ts
│   ├── ExpoKeyboardEventModule.ts
│   ├── ExpoKeyboardEventModule.web.ts
│   ├── ExpoKeyboardEventView.tsx
│   ├── ExpoKeyboardEventView.web.tsx
│   └── index.ts # 插件代码入口

走通流程

流程梗概:从模块入口index.ts,到*Module.ts(代码模块)和*View.ts(页面模块),到具体的iOS和安卓的原生模块类。
web平台会走平台拓展名的方式,没有最后一步,直接渲染JSX了就。

首先,从src/index.ts入口启动,src/index.ts中导出了ExpoKeyboardEventModule.tsExpoKeyboardEventView.ts,这两个文件分别负责执行原生代码,以及渲染原生视图:

1
2
3
4
5
// Reexport the native module. On web, it will be resolved to ExpoKeyboardEventModule.web.ts
// and on native platforms to ExpoKeyboardEventModule.ts
export { default } from './ExpoKeyboardEventModule';
export { default as ExpoKeyboardEventView } from './ExpoKeyboardEventView';
export * from './ExpoKeyboardEvent.types';

平台拓展名

得益于React Native的平台拓展名,不同的平台拓展名,会在对应的平台上被启用:
.web.ts.web.tsx会在web平台上被启用。
.native.ts.native.tsx会在原生平台,也就是iOS和安卓平台上被启用。
.ios.ts.ios.tsx对应iOS。
.android.ts.ios.tsx安卓。
简单来说,两个文件名相同,但是后缀名不同的文件,RN会去找跟自己平台名字后缀相符的文件去执行。这是处理平台兼容性的最好的办法。

好,现在流程来到ExpoKeyboardEventModule.ts以及ExpoKeyboardEventView.ts了。
这俩文件其实就是个代理,这两个文件分别利用requireNativeModule()requireNativeView()方法,把对应平台的原生代码桥接进来。
另外考虑到TypeScript类型问题,它在桥接进来后,会添加上我们需要的类型,也就是我们的模块需要具体哪些方法和参数,它大概是这么做的:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { NativeModule, requireNativeModule } from 'expo';

import { ExpoKeyboardEventModuleEvents } from './ExpoKeyboardEvent.types';

declare class ExpoKeyboardEventModule extends NativeModule<ExpoKeyboardEventModuleEvents> {
PI: number;
hello(): string;
setValueAsync(value: string): Promise<void>;
}

// requireNativeModule方法加载原生模块,并按照ExpoKeyboardEventModule类型导出
// 此时,外界“认识”了这个模块,知道其内部有`hello()`和`setValueAsync()`方法
export default requireNativeModule<ExpoKeyboardEventModule>('ExpoKeyboardEvent');

ExpoKeyboardEventView的基本流程也是一样的,利用requireNativeView()方法,引入原生的UI页面,然后使用泛型等方法,赋予其TS类型。

好的,流程终于到了原生代码部分了。
上面的各种方法和常量比如setValueAsync()PI,到目前只是方法的定义部分,真正的实现部分在原生代码里面,所以原生代码的方法和常量要与TS的方法定义一一对应。

Expo为此专门创造了一套Module API,可以让我们用Swift和Kotlin来编写原生模块,并且由于Swift和Kotlin本身的语言特性,安卓和iOS平台的原生模块代码相似度高达99%!可以说是开发成本极低!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import ExpoModulesCore

public class ExpoKeyboardEventModule: Module {

/// 定义了一个原生模块
public func definition() -> ModuleDefinition {
/// 设置该模块的名字,这个名字跟上面的requireNativeModule()方法传入的名字要对应。
Name("ExpoKeyboardEvent")

/// 该模块的常量导出,是个字典,上面写的ExpoKeyboardEventModule.PI就对应这里的Double.pi
Constants([
"PI": Double.pi
])

/// 定义这个模块的事件名,允许写多个,后面可以用sendEvent("onChange")向RN发送事件
Events("onChange")

/// 同步方法,对应ExpoKeyboardEventModule.hello()
Function("hello") {
return "Hello world! 👋"
}

/// 异步方法,带参数,对应上面的setValueAsync(value: string): Promise<void>方法
/// 如果你希望这个异步方法带返回值,那么可以返回一个Promise,把方法签名改成下面这样就行
/// AsyncFunction("setValueAsync") { (value: String, promise: Promise) in
/// promise.resolve()
/// }
AsyncFunction("setValueAsync") { (value: String) in
/// 这是向RN发送事件,RN端可以用addEventListener或者expo的useEvent() hook 来监听这里发送的事件
self.sendEvent("onChange", [
"value": value
])
}

/// 给这个模块定义了一个原生View,TS端可以用requireNativeView('ExpoKeyboardEvent')拿到这个View,注意参数传递的是模块名不是View名,View没有名字。
View(ExpoKeyboardEventView.self) {
/// 这个View会接受一个`url`属性,这里拿到这个属性后会调用ExpoKeyboardEventView。webview.load()方法,加载URL
/// 第一个参数view是固定参数,第二个参数才是实际参数
Prop("url") { (view: ExpoKeyboardEventView, url: URL) in
if view.webView.url != url {
view.webView.load(URLRequest(url: url))
}
}

/// 同样的,View也可以有自己的事件
Events("onLoad")
}
/// 调用:<ExpoKeyboardEventView url={"https://www.bilibili.com/"} />
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package expo.modules.keyboardevent

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.net.URL

class ExpoKeyboardEventModule : Module() {
/// 跟Swift一样的,以下是模块的实际定义部分
override fun definition() = ModuleDefinition {
/// 模块名
Name("ExpoKeyboardEvent")

/// 模块常量导出
Constants(
"PI" to Math.PI
)

/// 模块事件名
Events("onChange")

/// 同步方法
Function("hello") {
"Hello world! 👋"
}

// 异步方法
AsyncFunction("setValueAsync") { value: String ->
/// 向RN发送事件,注意事件名一定要在上面定义的事件名之内
sendEvent("onChange", mapOf(
"value" to value
))
}

/// 原生UI模块
View(ExpoKeyboardEventView::class) {
/// 原生UI模块的prop
Prop("url") { view: ExpoKeyboardEventView, url: URL ->
view.webView.loadUrl(url.toString())
}
/// 原生UI模块的事件名
Events("onLoad")
}
}
}

解释一些问题,JS的Promise到了原生端,会在方法最后加一个Promise类型的参数,调用该参数的reject()或者resolve()方法来返回内容或者报错。

原生View里面,Prop("url") { (view: ExpoKeyboardEventView, url: URL) in接受了一个Swift的URL参数,可是RN里面明明传入的是string啊?怎么变成URL的?
原来,Expo内部对一些基本的类型做了转换,凡是符合URL格式的字符串,到原生代码里面就变成URL了。具体的类型转换可以看这里

那么Web呢?
上面说了,借助平台拓展名,web平台调用这个插件的时候,实际会进入到ExpoKeyboardEventModule.web.tsExpoKeyboardEventView.web.ts两个文件里面,不会再继续转发。那么此时你在开发的是普通的npm包,在这两个文件内分别实现模块方法和页面内容即可。
需要注意的是,模块方法需要跟ExpoKeyboardEventModule.ts保持一致。ExpoKeyboardEventView.web.ts内,原生层会返回一个原生的View对象,在这里,直接渲染JSX即可,此时你是在开发React程序,而不是RN程序。

总结

RN和Expo 模块精义,如果你需要编写的原生代码部分没有UI页面,就找XXXModule。反之如果需要写页面,就找XXXView

原生代码返回内容到Expo

上面的流程基本上是走通了,目前来说已经实现了RN调用原生安卓、原生iOS、Web平台的代码。那么反过来,调用后怎么拿到返回值呢?原生端怎么主动向RN端发送事件和数据呢?

同步方法

上面的代码例子里面写过,Function("hello")用于创建一个同步方法。同步方法会阻塞原生线程,直到这个方法返回。
上面的例子中定义了一个同步方法叫做hello(),则RN中可以直接这么调用:

1
2
<Text>{ExpoKeyboardEvent.hello()}</Text>
/// 输出 Hello world! 👋

同步方法最多能接受8个参数,这是Swift和Kotlin的语言限制。
同步方法会阻塞原生线程,还会拖慢程序的响应速度。官方建议,在执行IO任务,比如发送网络请求,或者进行文件操作,或者需要在子线程上执行等耗时操作时,必须使用异步方法。

异步方法

AsyncFunction("setValueAsync")用于定义一个异步方法。异步方法会在子线程中执行,这种方法的返回值是一个Promise。假设此时你直接return true,那么Expo会自动帮你隐式promise.resolve(true)。如果方法本身有异步操作,还可以把Promise参数显式写出来,手动调用resolve()或者reject(),更加灵活。

1
2
3
4
5
6
7
8
9
AsyncFunction("myAsyncFunction") { (message: String) in
/// 你可以直接返回值
return message
}

AsyncFunction("myAsyncFunction") { (message: String, promise: Promise) in
/// 也可以经过一番操作后,手动调用promise.resolve()
promise.resolve(message)
}
1
2
3
4
5
6
7
8
9
10
AsyncFunction("myAsyncFunction") { message: String ->
return@AsyncFunction message
}
/// 一样的,可以直接返回值,也可以手动resolve
/// return@AsyncFunction 是Kotlin的语言特性,表示return掉的是AsyncFunction方法本身,避免层级问题

// Make sure to import `Promise` class from `expo.modules.kotlin` instead of `expo.modules.core`.
AsyncFunction("myAsyncFunction") { message: String, promise: Promise ->
promise.resolve(message)
}

讲道理,Expo的开发者的确全是人才(褒义),这么封装一下,Swift和Kotlin代码除了闭包的in换成->,其他几乎一毛一样,简直是艺术!

同步方法和异步方法除了Function换成AsyncFunction,前者没有Promise之外,其他写法都是一样的。

发送事件

事件用于持续性的、多次的传递信息。
其流程依次是是注册事件、发送事件、监听事件。

注册事件

1
Events("onChange", "onEdit", "onUpdate", "aaa", "bbb")

Swift和Kotlin的语法一样的,使用Events()方法,可以向RN暴露出一堆事件名,用于RN代码中根据事件名监听。

发送事件

原生模块内,调用sendEvent()方法向RN发送事件。注意事件名必须要是上面注册过的。
需要注意的是,模块内发送事件和View发送事件有一点点区别,模块类内发送事件直接调用sendEvent(eventName: string, payload: Map)

1
2
3
4
5
AsyncFunction("setValueAsync") { (value: String) in
self.sendEvent("onChange", [
"value": value
])
}
1
2
3
4
5
6
7
AsyncFunction("setValueAsync") { value: String ->
sendEvent(
"onChange", mapOf(
"value" to value
)
)
}

kotlin的mapOf()bundleOf()arrayOf()语法多少有点反人类了哈…

原生视图内的发送事件比模块内的稍微麻烦一点。
首先,你在模块内使用View() {}为这个模块定义了一个原生视图。在原生视图定义里面,使用Events(...)定义了一堆视图内的事件名。流程看起来跟模块的发送事件差不多啊,可是原生视图类在另一个文件里面啊?我在它里面怎么调用这里的sendEvent()呢?
这里其实Expo做了一点黑魔法。在原生视图类内,创造一个神奇的EventDispatcher类的实例,保持变量名跟其中一个事件名一致,此时变量名就是事件名,调用该变量即可。没错,就是调用这个变量。这当然借助于Swift的神奇的callAsFunction()方法,将变量当作方法来调用。

假设你在View内定义了这样一堆事件:Events('onChange', 'onFinish'),那么你在原生代码内,像这样写:

1
2
3
4
5
6
7
8
/// 这里相当于创建了一个专用的事件发送器,只能用于给`onChange`事件发送消息
let onChange = EventDispatcher()
let onFinish = EventDispatcher()

/// 调用,传递数据
onChange(["key": "value", "key1": "value1"])
onFinish(["key2": "value2"])

1
2
3
4
5
6
7
/// 利用Kotlin的by关键字特性,onChange被委托给EventDispatcher(),可以理解为后者是一个监听器
private val onChange by EventDispatcher()
private val onFinish by EventDispatcher()

/// 调用
onChange(mapOf("key1" to "value1"))
onFinish(mapOf("key2" to "value2"))

监听事件

监听模块事件

上面说了,原生端暴露事件之后,RN端就可以用addListener来对模块监听事件,比如下面这样:

1
2
3
4
5
6
7
const ExpoKeyboardEventModule = requireNativeModule('ExpoKeyboardEvent');

ExpoKeyboardEventModule.addEventListener('onChange', (event: ExpoKeyboardEventChangeEvent) => {
console.log(event)
});

/// 不要忘了removeListener()

本质上就是对模块内的某个事件名字进行监听。
Expo又对监听的过程进行了一次封装,它提供了一个useEvent() hook,传入模块对象,以及事件名即可,hook本身会自己做清理工作。

1
2
3
/// 此时onChangePayload得到的就是上面sendEvent()的第二个参数发送的payload字段本身
const onChangePayload = useEvent(ExpoKeyboardEvent, "onChange");
console.log(onChangePayload.key)
监听原生视图事件

原生视图事件就更简单了,它用起来跟普通的View Props是一样的。比如你在View内注册了onLoad事件,那么这个原生视图在RN端就会产生一个onLoad()属性,就像这样:

1
2
3
const onLoad = useCallback((event) => {}, [])

<ExpoKeyboardEventView onLoad={onLoad} />

监听器的生命周期随着ExpoKeyboardEventView一起,不需要关心清理监听器的事情。

到这一步,原生端向RN端回传数据的知识也算是记录完了。那么问题又来了,web呢?web的同步方法异步方法以及Promise不需要处理,那么发送事件呢?
首先,模块事件。ExpoKeyboardEventModule.web.ts的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { registerWebModule, NativeModule } from 'expo';

import { ExpoKeyboardEventModuleEvents } from './ExpoKeyboardEvent.types';
/// Web 模块继承自EventEmitter,你可以在模块内调用this.emit()来发送事件
class ExpoKeyboardEventModule extends NativeModule<ExpoKeyboardEventModuleEvents> {
PI = Math.PI;
async setValueAsync(value: string): Promise<void> {
this.emit('onChange', { value });
}
hello() {
return 'Hello world! 👋';
}
}

export default registerWebModule(ExpoKeyboardEventModule);

至于视图事件,上面说了,视图事件跟普通的组件参数没有区别,所以你在你的React组件里面加一个同名的参数,并且手动调用就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import * as React from 'react';

import { ExpoKeyboardEventViewProps } from './ExpoKeyboardEvent.types';

export default function ExpoKeyboardEventView(props: ExpoKeyboardEventViewProps) {
/// 你在这里拿到了props.onLoad,那么你只需要在合适的时机调用就行了。
return (
<div>
<iframe
style={{ flex: 1 }}
src={props.url}
onLoad={() => props.onLoad({ nativeEvent: { url: props.url } })}
/>
</div>
);
}

模块部分终于算是写完了!到目前为止,RN调用原生和Web,以及向RN回传数据,都已经明了了!但是Expo模块带给我们的惊喜远不止如此!包括但不限于:

给原生视图添加自定义属性

比如,给ExpoKeyboardEventView添加一个属性叫做background,类型当然是Hex Color:

1
2
3
Prop("background") { (view: UIView, color: UIColor) in
view.backgroundColor = color
}

Expo内部给Hex Color自动做了转换,具体可以看文档中的Argument Types

1
2
3
Prop("background") { view: View, @ColorInt color: Int ->
view.setBackgroundColor(color)
}
插件生命周期监听
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// 插件初始化
OnCreate { }

/// 插件被销毁
OnDestroy { }

/// iOS程序进入后台
OnAppEntersBackground { }
/// iOS程序回到前台
OnAppEntersForeground { }

/// 安卓程序进入后台
OnActivityEntersBackground { }

/// 安卓程序回到前台
OnActivityEntersForeground { }

此外,如果你希望Expo能在RN和原生代码之间无缝传递你自定义的对象,那你可以参照文档中的Records。Expo把原生的类型做了封装,Expo可以帮助你自动读取JS对象的属性。
如果你希望在RN和原生代码中使用通用的枚举类型,那你可以参照文档中的Enums
如果你希望你的原生代码可以像TypeScript一样使用联合类型1 | 2,那你可以使用Eithers
如果你实在是难以描述JS对象的内容,那你可以使用JavaScript values,在原生端使用字典的形式处理JS对象。
如果你需要监听程序生命周期,以及iOS的AppDelegate回调方法等,那你可以参照官方的生命周期监听方案

Config Plugin

Config Plugin让你可以在app.json内配置原生项目的信息。
假设你开发的插件需要权限信息,我们知道,申请权限的时候需要在Info.plist以及AndroidManifest.xml中添加字段,在传统RN以及其他跨平台语言中,我们往往需要手动修改这些字段。借助Config Plugin,你可以自动化的修改工程信息,代码修改Info.plist或者AndroidManifest.xml文件,甚至是其他基础项目文件。
假设我的插件需要一个apiKey字段作为常量参数,那么我可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// plugin/src/index.ts
import {
withInfoPlist,
withAndroidManifest,
AndroidConfig,
ConfigPlugin,
} from 'expo/config-plugins';

const withMyApiKey: ConfigPlugin<{ apiKey: string }> = (config, { apiKey }) => {
/// 往iOS Info.plist中插入一个MY_CUSTOM_API_KEY字段
config = withInfoPlist(config, config => {
config.modResults['MY_CUSTOM_API_KEY'] = apiKey;
return config;
});

/// 往安卓AndroidManifest.xml中插入一个<meta-data />节点,名为MY_CUSTOM_API_KEY,值为参数传入的apiKey
config = withAndroidManifest(config, config => {
const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);

AndroidConfig.Manifest.addMetaDataItemToMainApplication(
mainApplication,
'MY_CUSTOM_API_KEY',
apiKey
);
return config;
});

return config;
};

export default withMyApiKey

此时执行npx expo prebuild,主项目的Info.plist以及AndroidManifest.xml分别多了一个字段。
此时你在主项目的app.json提供apiKey的内容:

1
2
3
4
5
6
7
{
"expo": {
...
"plugins": [["expo-keyboard-event", { "apiKey": "custom_secret_api" }]]
}
}

写入字段后,在原生代码里面需要读取我们插入的字段,如下所示:

1
2
3
4
5
6
7
8
9
10
11
import ExpoModulesCore

public class ExpoNativeConfigurationModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoNativeConfiguration")

Function("getApiKey") {
return Bundle.main.object(forInfoDictionaryKey: "MY_CUSTOM_API_KEY") as? String
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package expo.modules.nativeconfiguration

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import android.content.pm.PackageManager

class ExpoNativeConfigurationModule() : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoNativeConfiguration")

Function("getApiKey") {
val applicationInfo = appContext?.reactContext?.packageManager?.getApplicationInfo(appContext?.reactContext?.packageName.toString(), PackageManager.GET_META_DATA)

return@Function applicationInfo?.metaData?.getString("MY_CUSTOM_API_KEY")
}
}
}

这么做有什么优势呢?
你的插件现在可以变成纯自动挡了,不需要一半JS配,一半原生项目配了。可谓是非常优雅。
你现在不需要关心原生代码实现了,一切都能在JS代码里面完成。

到目前为止,这个插件本身就算是完成了,剩下的就是把这个插件发布到npm上。命令行执行npm login,然后npm publish即可。一个崭新的轮子就这么诞生了。

  • 标题: 关于Expo插件,以及原生能力
  • 作者: Aron
  • 创建于 : 2025-03-07 20:44:02
  • 更新于 : 2025-10-29 15:18:24
  • 链接: https://likeso.github.io/2025/03/07/about-expo-modules/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论