首页指南参考教程

教程:创建原生模块

有关使用 Expo 模块 API 创建原生模块的教程。


在本教程中,我们将构建一个存储用户首选应用主题的模块 - 黑暗、光明或系统。我们将在 iOS 上使用 UserDefaults,在 Android 上使用 SharedPreferences。可以使用 localStorage 实现 Web 支持,但我们将其留给读者作为练习。

¥In this tutorial, we are going to build a module that stores the user's preferred app theme - either dark, light, or system. We'll use UserDefaults on iOS and SharedPreferences on Android. It is possible to implement web support using localStorage, but we'll leave that as an exercise for the reader.

1. 初始化一个新模块

¥ Initialize a new module

首先,我们将创建一个新模块。在此页面上,我们将使用名称 expo-settings/ExpoSettings。你可以将其命名为任何你喜欢的名称,只需相应地调整说明即可:

¥First, we'll create a new module. On this page we will use the name expo-settings/ExpoSettings. You can name it whatever you like, just adjust the instructions accordingly:

Terminal
npx create-expo-module expo-settings

提示:由于你实际上不会发布此库,因此你可以点击 return 以接受所有提示的默认值。

¥Tip: Since you aren't going to actually ship this library, you can hit return for all the prompts to accept the default values.

2. 设置我们的工作区

¥ Set up our workspace

现在让我们稍微清理一下默认模块,以便我们有更多的干净状态,并删除我们在本指南中不会使用的视图模块。

¥Now let's clean up the default module a little bit so we have more of a clean slate and delete the view module that we won't use in this guide.

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

找到以下文件并将其替换为提供的最小样板:

¥Find the following files and replace them 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 编译器来监视更改并重建 JavaScript 模块,并在另一个终端窗口中单独编译并运行示例应用。

¥Now let's run the example project to make sure everything is working. We'll need to start the TypeScript compiler to watch for changes and rebuild the module JavaScript, and separately in another terminal window we'll compile and run the example app.

Terminal
# Run this in the root of the project to start the TypeScript compiler
npm run build
Terminal
cd example
# Run the example app on iOS
npx expo run:ios
# Run the example app on Android
npx expo run:android

我们现在应该看到文本“主题:当我们启动示例应用时,屏幕中央会显示“system”。值 "system" 是同步调用原生模块中的 getTheme() 函数的结果。我们将在下一步中更改该值。

¥We should now see the text "Theme: system" in the center of the screen when we launch the example app. The value "system" is the result of synchronously calling the getTheme() function in the native module. We'll change this value in the next step.

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

¥ Get, set, and persist the theme preference value

iOS 原生模块

¥iOS native module

要在 iOS 上读取该值,我们可以在键 "theme" 下查找 UserDefaults 字符串,如果没有,则回退到 "system"

¥To read the value on iOS, we can look for a UserDefaults string under the key "theme", and fall back to "system" if there isn't any.

要设置该值,我们可以使用 UserDefaultsset(_:forKey:) 方法。我们将使 setTheme 函数接受 String 类型的值。

¥To set the value, we can use UserDefaults's set(_:forKey:) method. We'll make our setTheme function accept 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"
    }
  }
}

Android 原生模块

¥Android native module

要读取该值,我们可以在键 "theme" 下查找 SharedPreferences 字符串,如果没有,则返回到 "system"。我们可以使用 getSharedPreferences()reactContext(React Native ContextWrapper)获取 SharedPreferences 实例。

¥To read the value, we can look for a SharedPreferences string under the key "theme", and fall back to "system" if there isn't any. We can get the SharedPreferences instance from the reactContext (a React Native ContextWrapper) using getSharedPreferences().

要设置该值,我们可以使用 SharedPreferencesedit() 方法来获取 Editor 实例,然后使用 putString() 来设置该值。我们将使 setTheme 函数接受 String 类型的值。

¥To set the value, we can use SharedPreferences's edit() method to get an Editor instance, and then use putString() to set the value. We'll make our setTheme function accept 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)
  }
}

TypeScript 模块

¥TypeScript module

现在我们可以从 TypeScript 调用我们的原生模块。

¥Now we can call our 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

现在,我们可以在示例应用中使用 Settings API。

¥We can now use the Settings API in our 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 we re-build and run the app, we'll see the "system" theme is still set. When we press the button, nothing happens. When you reload the app, you'll see the theme has changed. This is because we're never fetching the new theme value and re-rendering the app. We'll fix this in the next step.

5. 发出主题值的更改事件

¥ Emit change events for the theme value

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

¥We can ensure that developers using our API can react to changes in the theme value by emitting a change event when the value changes. We'll use the Events definition component to describe events that our module can emit, sendEvent to emit the event from native, and the EventEmitter API to subscribe to events in JavaScript. Our event payload will be { theme: string }.

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"
    }
  }
}

Android 原生模块

¥Android native module

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

¥Events payloads are represented as Bundle instances on Android, which we 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)
  }
}

TypeScript 模块

¥TypeScript module

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

const emitter = new EventEmitter(ExpoSettingsModule);

export type ThemeChangeEvent = {
  theme: string;
};

export function addThemeListener(listener: (event: ThemeChangeEvent) => void): Subscription {
  return emitter.addListener<ThemeChangeEvent>('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 * as React from 'react';
import { Button, Text, View } from 'react-native';

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

  React.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 for us to make a mistake when using the Settings.setTheme() API in its current form, because we can set the theme to any string value. We can improve the type safety of this API by using an enum to restrict the possible values to system, light, and dark.

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
  }
}

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")
}

TypeScript 模块

¥TypeScript module

src/index.ts
import { EventEmitter, Subscription } from 'expo-modules-core';

import ExpoSettingsModule from './ExpoSettingsModule';

const emitter = new EventEmitter(ExpoSettingsModule);

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

export type ThemeChangeEvent = {
  theme: Theme;
};

export function addThemeListener(listener: (event: ThemeChangeEvent) => void): Subscription {
  return emitter.addListener<ThemeChangeEvent>('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 we change Settings.setTheme(nextTheme) to Settings.setTheme("not-a-real-theme"), TypeScript will complain, and if we ignore that and go ahead and press the button, we'll see the following 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 是唯一有效的值。

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

下一步

¥Next steps

恭喜!你已经为 Android 和 iOS 创建了第一个简单但不平凡的 Expo 模块。你可以继续学习下一个教程,了解如何创建原生视图。

¥Congratulations! You have created your first simple yet non-trivial Expo module for Android and iOS. You can continue to the next tutorial to learn how to create a native view.

Expo 中文网 - 粤ICP备13048890号