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

本文是 Chitraksh Tarun 的客座文章。他是一名软件开发工程师,目前在 ClubzFM 担任 SWE 实习生。他同时也是一名 Minecraft 速通玩家,自 2021 年 4 月起开始速通这款游戏,并且是 PaceMan.gg 移动应用的维护者。
...
Minecraft 速通社区的节奏极快,是那种快得惊人的快。速通随时在世界各地发生,社区希望实时跟进那些可能打破纪录的速通。
PaceMan.gg 是一个社区驱动的工具,用于追踪正在进行的 Minecraft 速通。它会突出显示进行中的速通,并通过实时排行榜提供进度更新。
PaceMan.gg 的移动应用基于 Expo SDK 构建,通过移动端界面提供实时速通更新。我们最近为这个应用加入了推送通知支持。简单来说,当某次速通进入有希望的阶段时,用户会收到通知,从而可以在应用中跟进进度,如果正在 Twitch 直播,还能直接观看。
本文拆解了我们为了实现一个快速、实时的应用所做的架构设计与开发决策,也会介绍我们如何使用 expo-notifications、@expo/app-integrity 和 expo-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 社区开发者们的出色工作——他们打造了这个工具的配套组件、实用程序和接口,我是在此基础上构建应用的。特别感谢 Specnr、Boyenn、Saanvi、Duncan、Jojoe、RedLime、Cylo,以及其他许多让这个项目成为现实的开发者。
注意:PaceMan.gg 是一款由社区驱动的实时速通进度追踪应用。本应用与 Minecraft、Mojang 及 Microsoft 均无隶属关系,亦未获得其认可,且符合 Minecraft 使用准则。