教程:创建原生模块

关于使用 Expo Modules API 创建保留设置的原生模块的教程。


在本教程中,你将构建一个模块来存储用户首选的应用主题:深色、浅色或系统。在 Android 上使用 SharedPreferences,在 iOS 上使用 UserDefaults。你可以使用 localStorage 实现 Web 支持,但本教程不介绍这一点。

¥In this tutorial, you build a module that stores the user's preferred app theme: dark, light, or system. On Android, use SharedPreferences, and on iOS, use UserDefaults. You can implement web support with localStorage, but this tutorial does not cover that.

Watch: How to create a native module with the Expo Modules API
Watch: How to create a native module with the Expo Modules API

1

初始化一个新模块

¥Initialize a new module

首先,创建一个新模块。对于本教程,模块名为 expo-settingsExpoSettings。你可以选择不同的名称,但请调整说明以符合你的选择。

¥First, create a new module. For this tutorial, the module is named expo-settings or ExpoSettings. You can choose a different name, but adjust the instructions to match your choice.

Terminal
npx create-expo-module expo-settings
由于你实际上不会发布此库,因此你可以点击 return 以接受所有提示的默认值。

2

设置工作区

¥Set up workspace

清理默认模块以从头开始。删除视图模块,因为本指南不使用它。

¥Clean up the default module to start with a clean slate. Delete the view module since this guide does not use it.

Terminal
cd expo-settings
rm ios/ExpoSettingsView.swift
rm android/src/main/java/expo/modules/settings/ExpoSettingsView.kt
rm src/ExpoSettingsView.tsx src/ExpoSettings.types.ts
rm src/ExpoSettingsView.web.tsx src/ExpoSettingsModule.web.ts

找到以下文件并用提供的最小样板替换其内容:

¥Locate the following files and replace their contents with the provided minimal boilerplate:

ios/ExpoSettingsModule.swift
import ExpoModulesCore

public class ExpoSettingsModule: Module {
public func definition() -> ModuleDefinition {
  Name("ExpoSettings")

  Function("getTheme") { () -> String in
    "system"
  }
}
}
android/src/main/java/expo/modules/settings/ExpoSettingsModule.kt
package expo.modules.settings

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class ExpoSettingsModule : Module() {
override fun definition() = ModuleDefinition {
  Name("ExpoSettings")

  Function("getTheme") {
    return@Function "system"
  }
}
}
src/index.ts
import ExpoSettingsModule from './ExpoSettingsModule';

export function getTheme(): string {
return ExpoSettingsModule.getTheme();
}
example/App.tsx
import * as Settings from 'expo-settings';
import { Text, View } from 'react-native';

export default function App() {
return (
  <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
    <Text>Theme: {Settings.getTheme()}</Text>
  </View>
);
}

3

运行示例项目

¥Run the example project

启动 TypeScript 编译器以监视更改。

¥Start the TypeScript compiler to watch for changes.

Terminal
# Run this in the root of the project to start the TypeScript compiler
npm run build

在单独的终端窗口中,运行示例应用。

¥In a separate terminal window, run the example app.

Terminal
cd example
# Run the example app on iOS
npx expo run:ios
# Run the example app on Android
npx expo run:android

你应该看到文本“主题:启动示例应用时,屏幕中央会出现一个“系统”图标。"system" 的值来自同步调用原生模块中的 getTheme() 函数。你将在下一步中更改此值。

¥You should see the text "Theme: system" in the center of the screen when you launch the example app. The value "system" comes from synchronously calling the getTheme() function in the native module. You will change this value in the next step.

4

获取、设置和保留主题首选项值

¥Get, set, and persist the theme preference value

Android 原生模块

¥Android native module

要读取值,请在键 "theme" 下查找 SharedPreferences 字符串。如果密钥不存在,则默认为 "system"。使用 reactContext(React Native ContextWrapper)通过 getSharedPreferences() 访问 SharedPreferences 实例。

¥To read the value, look for a SharedPreferences string under the key "theme". If the key does not exist, default to "system". Use the reactContext (a React Native ContextWrapper) to access the SharedPreferences instance with getSharedPreferences().

要设置值,请使用 SharedPreferencesedit() 方法获取 Editor 实例。然后,使用 putString() 设置值。确保 setTheme 函数接受 String 类型的值。

¥To set the value, use the edit() method of SharedPreferences to get an Editor instance. Then, use putString() to set the value. Ensure the setTheme function accepts a value of type String.

android/src/main/java/expo/modules/settings/ExpoSettingsModule.kt
package expo.modules.settings

import android.content.Context
import android.content.SharedPreferences
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class ExpoSettingsModule : Module() {
override fun definition() = ModuleDefinition {
  Name("ExpoSettings")

  Function("setTheme") { theme: String ->
    getPreferences().edit().putString("theme", theme).commit()
  }

  Function("getTheme") {
    return@Function getPreferences().getString("theme", "system")
  }
}

private val context
get() = requireNotNull(appContext.reactContext)

private fun getPreferences(): SharedPreferences {
  return context.getSharedPreferences(context.packageName + ".settings", Context.MODE_PRIVATE)
}
}

iOS 原生模块

¥iOS native module

要在 iOS 上读取值,请在键 "theme" 下查找 UserDefaults 字符串。如果密钥不存在,则默认为 "system"

¥To read the value on iOS, look for a UserDefaults string under the key "theme". If the key does not exist, default to "system".

要设置值,请使用 UserDefaultsset(_:forKey:) 方法。确保 setTheme 函数接受 String 类型的值。

¥To set the value, use the set(_:forKey:) method of UserDefaults. Ensure the setTheme function accepts a value of type String.

ios/ExpoSettingsModule.swift
import ExpoModulesCore

public class ExpoSettingsModule: Module {
public func definition() -> ModuleDefinition {
  Name("ExpoSettings")

  Function("setTheme") { (theme: String) -> Void in
    UserDefaults.standard.set(theme, forKey:"theme")
  }

  Function("getTheme") { () -> String in
    UserDefaults.standard.string(forKey: "theme") ?? "system"
  }
}
}

TypeScript 模块

¥TypeScript module

现在,从 TypeScript 调用你的原生模块。

¥Now, call your native modules from TypeScript.

src/index.ts
import ExpoSettingsModule from './ExpoSettingsModule';

export function getTheme(): string {
return ExpoSettingsModule.getTheme();
}

export function setTheme(theme: string): void {
return ExpoSettingsModule.setTheme(theme);
}

示例应用

¥Example app

你现在可以在示例应用中使用设置 API。

¥You can now use the Settings API in your example app.

example/App.tsx
import * as Settings from 'expo-settings';
import { Button, Text, View } from 'react-native';

export default function App() {
const theme = Settings.getTheme();
// Toggle between dark and light theme
const nextTheme = theme === 'dark' ? 'light' : 'dark';

return (
  <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
    <Text>Theme: {Settings.getTheme()}</Text>
    <Button title={`Set theme to ${nextTheme}`} onPress={() => Settings.setTheme(nextTheme)} />
  </View>
);
}

当你重建并运行应用时,"system" 主题仍会设置。按下按钮不会执行任何操作,但当你重新加载应用时,主题会发生变化。发生这种情况的原因是应用没有获取新的主题值或重新渲染。你将在下一步中修复此问题。

¥When you rebuild and run the app, the "system" theme is still set. Pressing the button does nothing, but when you reload the app, the theme changes. This happens because the app does not fetch the new theme value or re-render. You will fix this in the next step.

5

发出主题值的更改事件

¥Emit change events for the theme value

确保使用你的 API 的开发者可以通过在值更新时发出更改事件来对主题值更改做出反应。使用 活动 定义组件来描述模块发出的事件,使用 sendEvent 从原生代码触发事件,使用 EventEmitter API 订阅 JavaScript 中的事件。事件有效负载是 { theme: string }

¥Ensure developers using your API can react to theme value changes by emitting a change event whenever the value updates. Use the Events definition component to describe the events your module emits, sendEvent to emit the event from native code, and the EventEmitter API to subscribe to events in JavaScript. The event payload is { theme: string }.

Android 原生模块

¥Android native module

事件有效负载在 Android 上表示为 Bundle 实例,你可以使用 bundleOf 函数创建它们。

¥Events payloads are represented as Bundle instances on Android, which you can create using the bundleOf function.

android/src/main/java/expo/modules/settings/ExpoSettingsModule.kt
package expo.modules.settings

import android.content.Context
import android.content.SharedPreferences
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class ExpoSettingsModule : Module() {
override fun definition() = ModuleDefinition {
  Name("ExpoSettings")

  Events("onChangeTheme")

  Function("setTheme") { theme: String ->
    getPreferences().edit().putString("theme", theme).commit()
    this@ExpoSettingsModule.sendEvent("onChangeTheme", bundleOf("theme" to theme))
  }

  Function("getTheme") {
    return@Function getPreferences().getString("theme", "system")
  }
}

private val context
get() = requireNotNull(appContext.reactContext)

private fun getPreferences(): SharedPreferences {
  return context.getSharedPreferences(context.packageName + ".settings", Context.MODE_PRIVATE)
}
}

iOS 原生模块

¥iOS native module

ios/ExpoSettingsModule.swift
import ExpoModulesCore

public class ExpoSettingsModule: Module {
public func definition() -> ModuleDefinition {
  Name("ExpoSettings")

  Events("onChangeTheme")

  Function("setTheme") { (theme: String) -> Void in
    UserDefaults.standard.set(theme, forKey:"theme")
    sendEvent("onChangeTheme", [
      "theme": theme
    ])
  }

  Function("getTheme") { () -> String in
    UserDefaults.standard.string(forKey: "theme") ?? "system"
  }
}
}

TypeScript 模块

¥TypeScript module

src/index.ts
import { EventSubscription } from 'expo-modules-core';
import ExpoSettingsModule from './ExpoSettingsModule';

export type ThemeChangeEvent = {
theme: string;
};

export function addThemeListener(listener: (event: ThemeChangeEvent) => void): EventSubscription {
return ExpoSettingsModule.addListener('onChangeTheme', listener);
}

export function getTheme(): string {
return ExpoSettingsModule.getTheme();
}

export function setTheme(theme: string): void {
return ExpoSettingsModule.setTheme(theme);
}

示例应用

¥Example app

example/App.tsx
import * as Settings from 'expo-settings';
import { useEffect, useState } from 'react';
import { Button, Text, View } from 'react-native';

export default function App() {
const [theme, setTheme] = useState<string>(Settings.getTheme());

useEffect(() => {
  const subscription = Settings.addThemeListener(({ theme: newTheme }) => {
    setTheme(newTheme);
  });

  return () => subscription.remove();
}, [setTheme]);

// Toggle between dark and light theme
const nextTheme = theme === 'dark' ? 'light' : 'dark';

return (
  <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
    <Text>Theme: {Settings.getTheme()}</Text>
    <Button title={`Set theme to ${nextTheme}`} onPress={() => Settings.setTheme(nextTheme)} />
  </View>
);
}

6

使用枚举提高类型安全性

¥Improve type safety with Enums

使用当前形式的 Settings.setTheme() API 时很容易出错,因为它允许任何字符串值。通过使用枚举将可能的值限制为 systemlightdark,提高此 API 的类型安全性。

¥It's easy to make mistakes when using the Settings.setTheme() API in its current form, as it allows any string value. Improve the type safety of this API by using an enum to restrict the possible values to system, light, and dark.

Android 原生模块

¥Android native module

android/src/main/java/expo/modules/settings/ExpoSettingsModule.kt
package expo.modules.settings

import android.content.Context
import android.content.SharedPreferences
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.types.Enumerable

class ExpoSettingsModule : Module() {
override fun definition() = ModuleDefinition {
  Name("ExpoSettings")

  Events("onChangeTheme")

  Function("setTheme") { theme: Theme ->
    getPreferences().edit().putString("theme", theme.value).commit()
    this@ExpoSettingsModule.sendEvent("onChangeTheme", bundleOf("theme" to theme.value))
  }

  Function("getTheme") {
    return@Function getPreferences().getString("theme", Theme.SYSTEM.value)
  }
}

private val context
get() = requireNotNull(appContext.reactContext)

private fun getPreferences(): SharedPreferences {
  return context.getSharedPreferences(context.packageName + ".settings", Context.MODE_PRIVATE)
}
}

enum class Theme(val value: String) : Enumerable {
LIGHT("light"),
DARK("dark"),
SYSTEM("system")
}

iOS 原生模块

¥iOS native module

ios/ExpoSettingsModule.swift
import ExpoModulesCore

public class ExpoSettingsModule: Module {
public func definition() -> ModuleDefinition {
  Name("ExpoSettings")

  Events("onChangeTheme")

  Function("setTheme") { (theme: Theme) -> Void in
    UserDefaults.standard.set(theme.rawValue, forKey:"theme")
    sendEvent("onChangeTheme", [
      "theme": theme.rawValue
    ])
  }

  Function("getTheme") { () -> String in
    UserDefaults.standard.string(forKey: "theme") ?? Theme.system.rawValue
  }
}

enum Theme: String, Enumerable {
  case light
  case dark
  case system
}
}

TypeScript 模块

¥TypeScript module

src/index.ts
import { EventSubscription } from 'expo-modules-core';

import ExpoSettingsModule from './ExpoSettingsModule';

export type Theme = 'light' | 'dark' | 'system';

export type ThemeChangeEvent = {
theme: Theme;
};

export function addThemeListener(listener: (event: ThemeChangeEvent) => void): EventSubscription {
return ExpoSettingsModule.addListener('onChangeTheme', listener);
}

export function getTheme(): Theme {
return ExpoSettingsModule.getTheme();
}

export function setTheme(theme: Theme): void {
return ExpoSettingsModule.setTheme(theme);
}

示例应用

¥Example app

如果将 Settings.setTheme(nextTheme) 更改为 Settings.setTheme("not-a-real-theme"),TypeScript 将引发错误。如果你忽略错误并按下按钮,你将看到以下运行时错误:

¥If you change Settings.setTheme(nextTheme) to Settings.setTheme("not-a-real-theme"), TypeScript will raise an error. If you ignore the error and press the button, you will see the following runtime error:

ERROR  Error: FunctionCallException: Calling the 'setTheme' function has failed (at ExpoModulesCore/SyncFunctionComponent.swift:76)
→ Caused by: ArgumentCastException: Argument at index '0' couldn't be cast to type Enum<Theme> (at ExpoModulesCore/JavaScriptUtils.swift:41)
→ Caused by: EnumNoSuchValueException: 'not-a-real-theme' is not present in Theme enum, it must be one of: 'light', 'dark', 'system' (at ExpoModulesCore/Enumerable.swift:37)

错误消息的最后一行显示 not-a-real-theme 不是 Theme 枚举的有效值。唯一有效的值是 lightdarksystem

¥The last line of the error message shows that not-a-real-theme is not a valid value for the Theme enum. The only valid values are light, dark, and system.

恭喜!你已经为 Android 和 iOS 创建了第一个 Expo 模块。

¥Congratulations! You have created your first Expo Module for Android and iOS.

下一步

¥Next steps

Expo 模块 API 参考

使用 Kotlin 和 Swift 创建原生模块。

了解应用大小

使用 Expo Modules API 创建原生视图的教程。