使用共享对象

编辑页面

了解如何使用 Expo Modules API 中的共享对象。


For the complete documentation index, see llms.txt. Use this Use this file to discover all available pages.

Shared objects 让你可以将 Android 和 iOS 中长期存在的 native 实例暴露给应用的 JavaScript/TypeScript,而不需要让它们的生命周期控制权交给 JavaScript。它们可用于在 React 组件之间保留重量级状态对象,例如已解码的位图,而不是每次组件挂载时都创建一个新的 native 实例。

在本指南中,我们将了解什么是 shared object,以及它们在 native 平台上是如何实现的。

什么是 shared object?

shared object 是一种自定义类,它通过 Expo 模块将来自 Android 和/或 iOS 的 native 实例桥接到你应用的 JavaScript/TypeScript 代码中。在 Kotlin 和 Swift 的 native 侧,你通过继承 SharedObject 来声明该类,并在模块定义中使用 Class() 将其暴露出来。只要 JavaScript 和 native 任一方不再持有引用,shared object 就会自动释放。

为什么使用 shared object?

图像等大型媒体资源在解码到内存后,可能会超过数 MB。如果没有 shared object,在应用的不同部分之间传递这些资源会迫使每个部分每次都从磁盘重新加载,并多次解码同一个文件。这会增加内存压力,造成 I/O 瓶颈,导致掉帧,甚至引发电量消耗。

Shared object 通过在内存中保留一个单一的 native 实例,同时让多个 JavaScript 引用指向它,来解决这个问题。

示例:无需磁盘 I/O 的图像处理

为了理解 shared object,我们来看一个示例:你需要旋转并翻转用户在应用中选取的一张图片,然后在处理后将其显示在你的应用中。

不使用 shared object

从历史上看,native 模块通常以 无状态 的方式编写,也就是每个函数独立运行,不在调用之间维护状态。如果你想对同一个对象(例如图像文件)执行两个独立操作,你就需要在两个地方都从磁盘加载它,并在每次都重复 I/O 操作。

如果没有 shared object,ImagePicker 会从类似 "file:///path/to/image.jpg" 这样的文件 URI 读取并将图片解码到内存中。随后图像处理模块再次读取同一个 URI,并把图片再次解码到内存中。当应用用户调用转换方法(例如 rotate())来旋转图片时,该模块会把旋转后的图片保存到一个新文件中。最后,当新的 URI 传递给 Image 组件时,它会再次从磁盘解码图片以进行渲染。这个工作流会产生两次或更多次解码和磁盘读取操作。

使用 shared object

使用 shared object 后,同样的场景会高效得多。ImagePicker 读取 URI,并将图片一次性解码到一个 shared object 中。当应用用户调用转换方法(例如 rotate())时,模块会直接在内存中的位图上进行处理,而不会写入磁盘。如果你需要文件输出,可以调用一个显式的保存函数(例如图像处理器中的 saveAsync),否则转换只会保留在内存中。

最后,shared object 会被传递给 Image 组件,这一次图片从内存中渲染。整个工作流只需要一次磁盘读取和一次解码操作,所有转换都在内存中完成。

性能提升非常显著。通过消除重复的磁盘 I/O 和解码操作,你只保留了一份位图在内存中,而不是多份副本。这会降低 CPU 使用率,有助于延长电池续航,并减少因内存压力导致崩溃的风险。

Shared object 还带来了更方便的面向对象 API 形态。你可以在一个长期存在的实例上暴露方法(例如 rotate(), flipX(), renderAsync()),让调用方在这个有状态对象上链式调用操作,而不是暴露一组扁平的无状态函数。

使用 shared object 的实现

既然你已经了解了 shared object 的用途,接下来我们来看一个最小实现,它演示了前面示例中的核心概念。

这个示例创建了一个简单的图像处理模块:它从文件路径加载图像,在内存中应用转换(旋转和翻转),并暴露一个可被其他模块消费的共享引用。

Android 实现

在 Android 中,你可以从 expo.modules.kotlin.sharedobjects.SharedObject 提供的 SharedObject 类创建 shared object。这个类负责管理解码后的位图并暴露用于处理它的方法。该实现只在内存中保留当前图像,并就地应用转换,因此只有在旋转或翻转产生新位图时,你才会分配一个新的位图:

import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.sharedobjects.SharedObject class ImageRef : SharedRef<Bitmap>() class SimpleImageContext( runtimeContext: RuntimeContext, bitmap: Bitmap ) : SharedObject(runtimeContext) { private var current: Bitmap = bitmap fun rotate(degrees: Float) = apply { val matrix = Matrix().apply { postRotate(degrees) } current = Bitmap.createBitmap(current, 0, 0, current.width, current.height, matrix, true) } fun flipX() = apply { val matrix = Matrix().apply { preScale(-1f, 1f) } current = Bitmap.createBitmap(current, 0, 0, current.width, current.height, matrix, true) } fun render(): ImageRef = ImageRef(current, runtimeContext) override fun sharedObjectDidRelease() { if (!current.isRecycled) current.recycle() } }

上面的示例与 iOS 实现非常相似。不过,在 Android 上有一个不同之处:sharedObjectDidRelease() 方法。该生命周期回调会在 JavaScript 释放对 shared object 的所有引用时被调用,从而提供清理 native 资源的机会。

当这个类的结果传递给另一个模块时,render 方法会返回一个 ImageRef,它是一种专门的 SharedRef<Bitmap> 类型,expo-image 和其他了解图像的模块已经能够识别它。

模块定义会暴露一个用于创建上下文的异步函数,以及一个用于绑定方法的类定义。Expo Modules API 使用声明式语法,在其中你指定模块名称、创建实例的函数,以及将方法映射到 shared object 的类定义:

import android.graphics.Bitmap import android.graphics.BitmapFactory import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition class SimpleImageModule : Module() { override fun definition() = ModuleDefinition { Name("SimpleImageModule") AsyncFunction("createContextAsync") { path: String -> val bitmap = BitmapFactory.decodeFile(path) ?: throw Exceptions.IllegalArgument("无法解码位于 $path 的图像") SimpleImageContext(runtimeContext, bitmap) } Class<SimpleImageContext>("Context") { Function("rotate") { ctx: SimpleImageContext, degrees: Float -> ctx.rotate(degrees) } Function("flipX") { ctx: SimpleImageContext -> ctx.flipX() } AsyncFunction("renderAsync") Coroutine { ctx: SimpleImageContext -> ctx.render() } } } }

在上面的示例中,createContextAsync 函数会从文件路径解码位图并返回一个新的 SimpleImageContext 实例。一旦上下文存在,rotateflipX 函数就会同步运行,因为它们只是在内存中进行处理。renderAsync 函数被标记为异步,是为了表明它可能涉及复制或准备位图以供其他模块使用。

iOS 实现

在 iOS 中,你可以通过继承 ExpoModulesCore 提供的 SharedObject 类创建 shared object。这个类负责管理解码后的位图并暴露用于处理它的方法。该实现只在内存中保留当前图像,并就地应用转换:

import ExpoModulesCore import UIKit final class ImageRef: SharedRef<UIImage> {} final class SimpleImageContext: SharedObject { private var current: UIImage init(path: String) throws { guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let image = UIImage(data: data) else { throw Exceptions.InvalidArgument() } self.current = image super.init() } func rotate(by degrees: Double) { current = current.rotated(degrees: degrees) } func flipX() { current = current.withHorizontallyFlippedOrientation() } func render() -> ImageRef { return ImageRef(current) } }

在上面的示例中,SimpleImageContext 读取图像文件,并在内存中保留单个 UIImagerotateflipX 方法会直接在内存中修改当前图像,而不会触碰磁盘。

当这个类的结果传递给另一个模块时,render 方法会返回一个 ImageRef,它是一种专门的 SharedRef<UIImage> 类型,expo-image 和其他了解图像的模块已经能够识别它。

现在,有了 shared object 类定义之后,你可以通过模块定义将其暴露出去。Expo Modules API 使用声明式语法,在其中你指定模块名称、创建实例的函数,以及将方法映射到 shared object 的类定义:

public final class SimpleImageModule: Module { public func definition() -> ModuleDefinition { Name("SimpleImageModule") AsyncFunction("createContextAsync") { (path: String) -> SimpleImageContext in return try SimpleImageContext(path: path) } Class("Context", SimpleImageContext.self) { Function("rotate") { (ctx, degrees: Double) -> SimpleImageContext in ctx.rotate(by: degrees) return ctx } Function("flipX") { (ctx: SimpleImageContext) -> SimpleImageContext in ctx.flipX() return ctx } AsyncFunction("renderAsync") { (ctx: SimpleImageContext) -> ImageRef in return ctx.render() } } } }

在上面的示例中,createContextAsync 是一个异步函数,因为从磁盘加载并解码图像属于 I/O 操作。一旦上下文存在,rotateflipX 函数就会同步运行,因为它们只是在内存中进行处理。renderAsync 函数被标记为异步,是为了表明它可能涉及复制或准备位图以供其他模块使用,不过在这个简单示例中它会立即返回。

在应用中使用 shared object

现在,你可以在应用的 JavaScript/TypeScript 代码中使用 shared object:从路径加载图像,创建已加载图像的上下文,链式执行内存中的转换,渲染以获取共享引用,然后将该引用传递给 Image 组件:

import { useState } from 'react'; import { Button } from 'react-native'; import { Image } from 'expo-image'; import type { SharedRef } from 'expo'; import SimpleImageModule from 'simple-image-module'; // 原生自定义模块 import { pickImageAsync } from './pickImage'; // 自定义 TypeScript 函数 export function SharedImageExample() { const [context, setContext] = useState(null); const [result, setResult] = useState<SharedRef<'image'> | null>(null); const load = async () => { const uri = await pickImageAsync(); if (!uri) { return; } const ctx = await SimpleImageModule.createContextAsync(uri); setContext(ctx); setResult(await ctx.renderAsync()); }; const rotateAndFlip = async () => { if (!context) { return; } setResult(await context.rotate(90).flipX().renderAsync()); }; return ( <> <Button title="Pick image" onPress={load} /> <Button title="Rotate 90° + flip X" onPress={rotateAndFlip} disabled={!context} /> {result && <Image source={result} style={{ width: 200, height: 200 }} />} </> ); }

在上面的示例中,React 组件只消费由图像处理上下文转换后的 native 图像,而该图像已经在内存中,并由 shared object(ImageRef)引用。因此,图像视图可以在下一帧立即显示图片,而链式转换完全不会触碰文件系统。

JavaScript API 使用 ImagePicker 选择图片,它返回一个标准的文件 URI。这个 URI 会被传递给自定义 native 模块,以在 SharedImageExample() 中创建一个 shared object:

import * as ImagePicker from 'expo-image-picker'; export async function pickImageAsync() { const result = await ImagePicker.launchImageLibraryAsync({ quality: 1, allowsMultipleSelection: false, }); if (result.canceled || !result.assets?.length) { return null; } // 此时我们仍然拥有一个磁盘 URI。 // native 模块会将其提升为 shared object。 return result.assets[0].uri; }

在上面的示例中,ImagePicker 不需要了解 shared object。它返回它应该返回的内容,也就是一个文件路径。你的 native 模块负责把这个路径转换为一个 shared object,以便其他 Expo 模块可以使用,例如 expo-image 中的 Image

使用共享对象的 Expo 库

以下是一些使用共享对象的 Expo SDK 库及其用途示例:

  • expo-image 库使用 SharedObject 来保持已解码的操作存活,并且视图组件在 Android 上接受 SharedRef<Bitmap>、在 iOS 上接受 SharedRef<UIImage>。这种设计允许图像在模块之间传递,而无需再次解码。要了解更多,请查看 expo-image 库在 AndroidiOS 上的源代码。
  • expo-image-manipulator 库展示了如何处理异步操作、排队多个操作,以及提供简洁的 JavaScript API。要了解更多,请查看 expo-image-manipulator 库在 AndroidiOS 上的源代码。
  • expo-sqlite 库使用共享对象来在多次调用之间保持数据库、会话和语句句柄,同时协调对底层数据库的访问。要了解更多,请查看 expo-sqlite 库在 AndroidiOS 上的源代码。
  • expo/fetch 库使用共享对象来保持请求和响应的生命周期,以支持流式传输、取消和重定向处理,同时提供一个兼容 JavaScript fetch 的 API。要了解更多,请查看 expo/fetch 库在 AndroidiOS 上的源代码。

共享对象的性能优势

使用共享对象可带来多项性能改进,例如:

  • 减少磁盘 I/O: 只进行一次读取操作,而不是在不同模块或函数调用之间进行多次读取
  • 更少的解码操作: 昂贵的解码(例如将 JPEG/PNG 解码为位图)只发生一次,而不是重复进行
  • 更低的内存压力: 内存中只有一个解码实例,而不是多个副本
  • 更快的操作: 内存中的转换速度明显快于基于磁盘的转换
  • 避免掉帧: 更少的 I/O 阻塞意味着更流畅的 UI 交互

类定义 DSL

当你使用 Class() 暴露一个共享对象时,类定义块除了 FunctionAsyncFunction 之外,还接受若干 DSL 组件。这些组件允许你直接在类上定义构造函数、静态方法和属性。

构造函数

定义一个构造函数,供 JavaScript 代码使用 new ClassName(args) 创建共享对象的新实例。如果没有 Constructor,实例只能通过返回共享对象的原生函数创建。

构造函数接收来自 JavaScript 的参数,并且必须返回共享对象类的一个实例。

Swift
Class(MySharedObject.self) { Constructor { (date: Date) in %%placeholder-start%%... %%placeholder-end%% } }
Kotlin
Class(MySharedObject::class) { Constructor { date: Date -> %%placeholder-start%%... %%placeholder-end%% } }

静态函数

定义类原型上的一个同步函数,可在 JavaScript 中通过 ClassName.functionName() 调用。与 Function 不同,StaticFunction 不接收实例作为参数。

Swift
StaticFunction("myStaticFunction") { in %%placeholder-start%%... %%placeholder-end%% }
Kotlin
StaticFunction("myStaticFunction") { -> %%placeholder-start%%... %%placeholder-end%% }

静态异步函数

定义类本身上的一个异步函数,可在 JavaScript 中通过 await ClassName.functionName() 调用。返回一个 Promise。在 Kotlin 中,你可以使用 Coroutine 修饰符来编写可挂起的函数体。

Swift
StaticAsyncFunction("myStaticAsyncFunction") { in %%placeholder-start%%... %%placeholder-end%% }
Kotlin
StaticAsyncFunction("myStaticAsyncFunction") { -> %%placeholder-start%%... %%placeholder-end%% }

属性

Class() 块中,Property 会像 Function 一样将类实例作为参数接收。这使你可以在共享对象实例上暴露计算属性。

Swift
Class(VideoPlayer.self) { Property("isPlaying") { (player: VideoPlayer) -> Bool in return player.isPlaying } Property("volume") .get { (player: VideoPlayer) -> Float in return player.volume } .set { (player: VideoPlayer, volume: Float) in player.volume = volume } }
Kotlin
Class(VideoPlayer::class) { Property("isPlaying") { player: VideoPlayer -> return@Property player.isPlaying } Property("volume") .get { player: VideoPlayer -> return@get player.volume } .set { player: VideoPlayer, volume: Float -> player.volume = volume } }
JavaScript
const player = new VideoPlayer(source); // 只读属性 console.log(player.isPlaying); // false // 可读写属性 player.volume = 0.5; console.log(player.volume); // 0.5

其他资源

Shared Objects 在 Expo Modules 中的真实影响

Shared Objects 解决了 Expo API 中许多基础性问题,同时还开启了一种设计面向对象 API 的全新方式。