Minecraft 速通社区如何借助 Expo 保持高效

了解 Minecraft 速通社区如何借助 Expo 构建移动应用与推送通知基础设施,实时向用户通知速通进展。

中文
复制
How the Minecraft Speedrunning Community stays fast with Expo

本文是 Chitraksh Tarun 的客座文章。他是一名软件开发工程师,目前在 ClubzFM 担任 SWE 实习生。他同时也是一名 Minecraft 速通玩家,自 2021 年 4 月起开始速通这款游戏,并且是 PaceMan.gg 移动应用的维护者。

...

Minecraft 速通社区的节奏极快,是那种快得惊人的快。速通随时在世界各地发生,社区希望实时跟进那些可能打破纪录的速通。

PaceMan.gg 是一个社区驱动的工具,用于追踪正在进行的 Minecraft 速通。它会突出显示进行中的速通,并通过实时排行榜提供进度更新。

PaceMan.gg 的移动应用基于 Expo SDK 构建,通过移动端界面提供实时速通更新。我们最近为这个应用加入了推送通知支持。简单来说,当某次速通进入有希望的阶段时,用户会收到通知,从而可以在应用中跟进进度,如果正在 Twitch 直播,还能直接观看。

本文拆解了我们为了实现一个快速、实时的应用所做的架构设计与开发决策,也会介绍我们如何使用 expo-notifications@expo/app-integrityexpo-glass-effect 来搭建通知基础设施、API 安全以及部分原生界面设计。

PaceMan.gg 移动应用

本质上,这个应用就是一个简洁、实时的 Minecraft 速通看板。首次打开时,首页标签会显示当前所有正在跑图的速通玩家,以及他们所处的阶段(split)。排行榜标签展示给定时间段内(每日、每周、每月、历史总榜)最快的成绩。统计标签则更全面地拆解速通玩家在不同 split 上的数据。它的定位就是一个简单、一眼可读的界面,让用户随时了解正在发生什么。

移动应用界面

应用完全用 Expo 开发(截至 v1.2.0 版本,99.5% 是 TypeScript,0.5% 是 Other 👀),并借助 Expo 的服务构建,从而在 iOS 和 Android 上轻松实现功能一致。事实上,应用最初几个版本是在没有 Mac 的情况下开发的:一台运行 WSL 的 Windows 设备、一部 iPhone,还有一台老旧的 Android 手机!Expo 的云服务让我们无需 Mac 就能构建并发布应用。

使用 Expo Notifications 实现推送通知

这个应用的核心功能之一,是在某场速通进入激动人心的节奏时收到通知。用户因此能实时获知正在进行的速通,如果该速通正在 Twitch 上直播,还能顺便去看一眼。

一个 express.js 微服务/后端(PushNotificationsService)负责管理通知,另一个微服务/后端(ActiveRunsService)负责管理当前活跃的速通。每个速通事件都会由 ActiveRunsService 通过 WebSocket 事件发送给 PushNotificationsService,后者解析并判断该事件是否值得通知(简单说,就是这场速通的节奏够不够好)。如果够好,就取出推送 token 并向它们发送通知。这部分集成了 expo-server-sdk-node,整体通过 MySQL 数据库和 Redis 实例来存储 token 及其他信息。

下面是这套系统思路的简化流程图:

流程图

客户端实现了一个 <NotificationsProvider />,灵感来自 Beto 的教程,它使用 expo-notifications 处理全部功能,从注册 token 到处理应用处于前台/后台时的通知事件。

用 App Integrity 保障安全

PushNotificationsService 后端还包含几个 API 路由,供移动应用执行 CRUD 操作(存储 token 以及一些偏好设置)。随着 @expo/app-integrity 包的推出,再加上这些 CRUD 的结构本就只打算给移动应用调用,我们决定用 App Integrity 给这些路由加一层安全防护。

在发起 API 请求之前,移动应用会运行一个 getIntegrityHeaders() 函数,针对一个唯一 challenge 执行 App Attest(iOS)或 Play Integrity(Android)校验,并把校验结果随请求一起传过去。后端有一个中间件函数 verifyIntegrity() 在路由之前运行,验证完整性,通过则继续执行 CRUD,失败则返回 401 - Unauthorized。后端使用 node-app-attest(iOS)和 @googleapis/playintegrity(Android)这两个库来完成这些校验。

以下是经过精简和清理后的 getIntegrityHeaders() 函数代码片段:

export const getIntegrityHeaders = async () => {
  if (Platform.OS !== "android" && Platform.OS !== "ios") return;
  // Get unique challenge
  const challenge = await getChallenge();
	// Handle Android
  if (Platform.OS === "android") {
    await waitForIntegrityProviderReady();
    const integrityToken = await requestIntegrityCheckAsync(challenge);
    return integrityToken;
  }
  // Handle iOS
  if (Platform.OS === "ios") {
    let keyId = await getItemAsync("app-attest-key");       
    // Attest first time, if no attestation available
    if (!keyId || typeof keyId !== "string" || keyId.trim().length === 0) {
      keyId = await generateKeyAsync();
      await setItemAsync("app-attest-key", keyId);
      const attestation = await attestKeyAsync(keyId, challenge);
      return attestation;
    }
		// Assert if attestation exists
    try {
      const request = {
        expoToken,
        challenge,
      };
      const assertion = await generateAssertionAsync(keyId, JSON.stringify(request));
      const rawAuthentication = JSON.stringify({
        keyId,
        assertion,
      });
      const authentication = Buffer.from(rawAuthentication).toString("base64");
      return authentication;
    } catch {
	    // Re-attest, if current attestation key fails for whatever reason.
      const newKeyId = await generateKeyAsync();
      await setItemAsync("app-attest-key", newKeyId);
      const attestation = await attestKeyAsync(newKeyId, challenge);
      return attestation;
    } 
  }
  return;
};

借助 App Integrity 应对这一独特挑战,再配合 expo-secure-store 将 key ID 安全存入 keychain,即可确保只有正版安装的应用才能访问后端,既安全又简洁。Notifications Provider 还会处理重新注册和 token 更新逻辑,这些同样由 App Integrity 保护,确保用户不会错过任何通知。

用 expo-glass-effect 实现原生 Header

我们希望 UI 的外观和手感尽可能接近原生。Header 便是一例。为 header 加上合适的模糊与样式效果后,滚动的感觉就与原生平台应有的表现一致了。这涵盖 Android(纯色 header)、iOS 26 之前的 iOS(header 半透明模糊),以及 iOS 26 及之后的 iOS(header 透明模糊)。

Anurabh Verma 的实现启发,同时使用 expo-glass-effect 包中的 isLiquidGlassAvailable() 处理函数,我们实现了一个 useScreenOptions() hook,让 header 在各页面之间保持一致:

// @/hooks/use-screen-options.ts 
import { useColorsForUI } from "@/hooks/use-colors-for-ui";
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { useColorScheme } from "nativewind";
import { Platform } from "react-native";

export const useScreenOptions = (): NativeStackNavigationOptions => {
  const { colorScheme } = useColorScheme(); 
  const { backgroundColor } = useColorsForUI(); // Custom hook to retrieve certain hex codes
  return {
    headerShadowVisible: false,
    headerTransparent: Platform.select({
      ios: true,
      android: false,
    }),
    headerStyle: {
      backgroundColor: Platform.select({
        android: backgroundColor,
      }),
    },
    headerBlurEffect: !isLiquidGlassAvailable()
      ? colorScheme === "light"
        ? "systemChromeMaterialLight"
        : "systemChromeMaterialDark"
      : "none",
    headerBackButtonDisplayMode: "minimal",
  };
};

用法如下:

// @/app/_layout.tsx
import { useScreenOptions } from "@/hooks/use-screen-options";
export default function RootLayout() {
	// ...
  const screenOptions = useScreenOptions();
  // ...
  return (
	  <Stack screenOptions={screenOptions}>
		  {/* Remaining Stack Elements */}
		</Stack>
	)

这让我们在滚动 header 时也能获得原生的感觉。

头部模糊效果

我们计划在包中的 <GlassView /> 组件上投入更多,逐步为应用加入更多 Liquid Glass UI 元素。我们想循序渐进地推进,让应用的 UI 保持克制又不失流畅。

PaceMan.gg 的未来计划

这个应用还有很多功能计划,比如 Widgets,也有不少改进和打磨的空间。我们期待更深入地使用 Expo 以及社区提供的配套技术,为 Minecraft Speedrunning 社区维持出色的移动端体验。

P.S. 我用 Expo 开发了这个移动应用,但这个项目离不开 Minecraft Speedrunning 社区开发者们的出色工作——他们打造了这个工具的配套组件、实用程序和接口,我是在此基础上构建应用的。特别感谢 SpecnrBoyennSaanviDuncanJojoeRedLimeCylo,以及其他许多让这个项目成为现实的开发者。

注意:PaceMan.gg 是一款由社区驱动的实时速通进度追踪应用。本应用与 Minecraft、Mojang 及 Microsoft 均无隶属关系,亦未获得其认可,且符合 Minecraft 使用准则

来源: Expo Blog← 返回首页