用 Expo 打造原生优先的社交平台

一个硕士项目如何长成支撑数千条目的社交词典 udictio,并证明 Expo 能作为核心架构而非仅起点。

中文
复制
Building a native-first social platform with Expo

udictio 从一个硕士项目起步,最终发展成了一个投入生产使用的社交平台,拥有数千条条目。

大多数社交帖子生来就是为了消失的。它们进入信息流,争夺注意力,然后很快再也找不回来。

我想做的是相反的东西:一个社交平台,让每一条贡献都始终附着在它所谈论的那个事物上。

这个想法后来成了 udictio,一个协作式的社交词典。一个人、一部电影、一件产品、一个地点、一场事件、一个想法或一种感受,都可以拥有一个永久的话题页。人们基于自己的知识和经历添加彼此独立的条目,而这些条目会一直留给下一个访问的人。

条目默认按时间顺序排列。不要求使用真实姓名,但每条条目仍然可以被举报,并通过审核机制移除。关注者和投票总数被刻意隐藏,没有广告,也没有算法来决定哪一条帖子该主导整个体验。

udictio 最初是我在内华达大学里诺分校 Media Innovation 项目的硕士课题。之后我继续开发这款移动应用大约两年,直到 2026 年上架 App Store。它现在支撑着数千条用户撰写的条目,以及个人资料、私信、通知、关注、审核、图片上传、投票、收藏、草稿、搜索、深度链接和一套完整的认证流程。

这款移动应用始终是一个 Expo 项目。

问题不在于 Expo 能否让我避开原生代码,而在于当我只在产品真正需要的地方加入原生代码时,Expo 还能不能继续作为项目的核心。

答案是能。

Expo 是架构,而不只是起点

udictio 使用 Expo Router、React Native、TypeScript、Apollo Client 和 Django GraphQL 后端。路由、通知、图片、安全存储、音频、语音、本地化、翻译、更新、符号、小组件、SwiftUI 控件、玻璃效果,以及许多更细碎的平台细节,都由 Expo 的包来处理。

官方包能解决的问题,我就用官方包。API 太新或太特殊时,我只补上尽可能小的原生边界:

  • 一个 config plugin,让原生工程的改动可复现

  • 若干 Swift 文件,编进主 app target,用于 App Intents

  • 一个职责单一的 Expo module,用于 Core Spotlight 索引

生成的 ios 目录不进 Git。Expo Prebuild 和 Continuous Native Generation 会在本地和 EAS Build 上重新生成它,而 app 配置、plugin 和原生源码始终是唯一的事实来源。

我不想要一个 React Native app 和一个手工维护的原生工程慢慢各走各的。我要的是一个统一的 Expo 工程,它的原生产物随时可以重新生成。

移动应用架构

不打开 App,通过 Siri 发帖到 udictio

最清楚的例子是 udictio 的发帖 intent。

用户可以说“在 udictio 里发一条”,给出话题和正文,然后从 Siri 收到确认。界面从不出现,React Native 运行时也从不启动。

视频:siriai-gif-16x9 — 在 expo.dev 上观看

这个约束决定了架构。后台 App Intent 不能假设 JavaScript 还活着。它必须在 Swift 里完成全部工作:收集参数、认证、发起请求、解释结果、返回有用的反馈。

下面这段代码做了简化,只为展示流程。生产版本包含校验、按状态区分的错误、严格的 URL 编码,以及额外的生命周期处理。

struct PostEntryIntent: AppIntent {
    static var openAppWhenRun = false

    func perform() async throws -> some IntentResult & ProvidesDialog {
        let topic = try await collectTopic()
        let content = try await collectEntry(for: topic)

        guard let token = SiriKeychain.readToken() else {
            return .result(
                dialog: "Open udictio and log in to turn on Siri posting."
            )
        }

        let result = try await SiriAPI.createEntry(
            topic: topic,
            content: content,
            token: token
        )

        return .result(dialog: result.spokenResponse)
    }
}

这个 intent 发送的是 App 用的同一个 GraphQL mutation,所以校验、审核、速率限制、封禁、话题创建仍然归 Django 后端管。

我也没有把 App 平时的会话凭证交给 Siri。后端单独签发了一个可撤销的 token,权限仅限于创建条目。React Native 用 expo-secure-store 存储它,Swift 读取同一个 Keychain 条目。退出登录会删除它,账号归属在本地校验,用户也可以单独关闭 Siri 发帖。

模拟器没有暴露的一个生命周期 bug

关于 App Intents 最有价值的一课,来自真机上的一次失败。

我最初的实现是先发起网络请求,再显示 Siri 提示。模拟器接受了这个顺序。在真机 iPhone 上,显示对话框会挂起 intent,请求随即被杀掉。

可靠的顺序是:

收集所有缺失的值

完成所有提示

发起网络请求

写入完成后,才提出打开 App

代码看起来没问题,模拟器也同意。真机不同意。

让 App Intents 留在 Expo 工作流里

udictio 还暴露了几个 intent:打开某个话题、读取热门话题、读取今天活跃的话题,以及打开某个话题并让摘要准备好运行。

Apple 的 App Intents 元数据提取不会发现放在 pod 里的 Swift 文件中的快捷指令。这些文件必须编译进主应用 target。

由于 iOS 工程是生成的,我没有通过改 Xcode 来解决。我写了一个 config plugin,把 Swift 源码复制到生成的 app 目录,并注册到 target 的 Sources build phase。这个 plugin 是幂等的,在本地 prebuild 和 EAS 构建时都会运行。

expo-widgets 构建的主屏幕小组件

udictio 并不围绕无限信息流展开,但它有一个不断变化的趋势话题列表。主屏幕因此成了一个自然的位置,让人一眼看到大家在写什么。

小组件支持 small、medium、large 三种尺寸,适配 light、dark、tinted、vibrant 渲染模式,点击后会打开 udictio 并选中趋势板块。

为 Expo 应用添加小组件

界面用 expo-widgets@expo/ui 搭起来。Expo 的 config plugin 会在 prebuild 阶段生成 widget extension 和共享的 App Group。

真正麻烦的地方不在布局,而在于要把 widget 当成一个独立的运行时和存储边界来对待。

widget 函数会被序列化,放到一个隔离的 JavaScriptCore 环境里执行。它没法随意访问任意模块状态,一些在 app 运行时里能用的写法,到了 widget 里行为就不一样。所以我把它的布局限制住,依赖写明确,数据契约保持很小。

快照数据还得能通过共享 App Group 里的 property list 存储。只要出现一个不支持的值,比如 undefinedNaN,或者一个无法序列化的对象,整条 timeline 就存不下来。

function buildWidgetProps(topics, iconUri) {
  const safeTopics = topics.map((topic) => ({
    title: String(topic.title ?? ''),
    slug: String(topic.slug ?? ''),
    count: Number.isFinite(topic.count)
      ? Math.trunc(topic.count)
      : 0,
  }));

  return typeof iconUri === 'string'
    ? { topics: safeTopics, iconUri }
    : { topics: safeTopics };
}

我加了一步开发期的回读,去确认数据到底有没有进到共享容器里,而不是假定更新成功了。topic 数据是立即推送的,图标则在后台复制进 App Group,之后再触发一次刷新。

视频:gif-widget-16x9 — 在 expo.dev 上观看

Expo 负责创建 extension target、App Group、原生 UI bridge 和更新机制。应用代码这边只需要遵守另一侧的生命周期和序列化规则。

在设备上汇总集体知识

udictio 的一些 topic 里包含许多彼此独立的观点。Apple 的 Foundation Models 框架让这件事成为可能:帮读者理解这些观点的全貌,又不必把内容发到第三方 AI 服务。

我用 react-native-apple-llm 作为通往 Apple 模型会话的桥梁。围绕它,我搭了产品自身的那套东西:可用性检查、按筛选条件收集条目、按 token 预算切块、map-reduce 式摘要、流式输出、取消、会话释放,以及面向用户的错误处理。

摘要入口只在 Apple Intelligence 可用、且某个 topic 至少有五条条目时出现。它总结的正是读者当前选中的那个视图,包括今天的条目、热门条目、带图片的条目或搜索结果。

视频:apple-intelligence-16x9 — 在 expo.dev 上观看

小主题只用一个模型会话。较大的主题会被切成有边界的片段,分别总结,再归并成一个最终结果。提示词反映了 udictio 的结构:条目是独立的定义、观察、轶事和经历,不一定是对话中的回复。

最重要的生产环境问题出现在用户在生成过程中关闭摘要时。取消之后,流式接口仍在接收原生事件,并试图写入已关闭的流,导致崩溃,被 Sentry 捕获。

我改用了事件发射器方案。每个事件都包含到目前为止的完整响应,取消只是停止 UI 更新,迟到的原生事件不再有害。每次运行还会创建并销毁自己的模型会话,而不是让模型不必要地常驻。

界面把这些机制都藏了起来。读者看到的是一个平静的“summarizing...”状态、一个受 Apple Intelligence 启发的 Skia 与 Reanimated 边框效果,然后是一段简洁的结果,并附上说明:内容在设备端生成,可能包含不准确之处。

目标不是取代原始条目,而是帮助读者决定他们想深入探索什么。

让整个应用显得协调的原生细节

最深入的集成是 App Intents、小组件和设备端摘要。还有几个较小的功能,展示了同一套 Expo 优先的思路如何延伸到产品的其余部分。

翻译: expo-translate-text 只翻译条目的正文和剧透部分。链接、提及、主题引用、图片和格式都保持原样。

朗读: 在 iOS 上,条目文本会用 Apple 的设备端语音合成器渲染成缓存的音频文件,并通过 expo-audio 播放,从而支持后台播放和系统媒体控制。

Spotlight: 一个小型 Expo 模块会把最近访问过的主题提交给 Core Spotlight,有效期 30 天。退出登录时会清除这些记录,因为浏览历史属于个人隐私。

故事分享: udictio 会渲染一张 9:16 的条目卡片,可发送到 Instagram 或 Facebook Stories,保存为 1080×1920 的图片,或通过系统分享面板分享。

iOS 26 与 Liquid Glass: Expo Router 提供原生导航、搜索、工具栏和表单面板。@expo/ui 增加了 SwiftUI 控件,而 expo-glass-effect 则有选择地使用,并在较早的 iOS 版本上采用自适应材质作为回退。

原生功能

这些功能用到不同的 Apple 框架,但始终是同一个 Expo 应用的延伸,各自聚焦一件事。

这套做法为什么能持续

有三个决定,比任何单个 API 都更重要。

把原生边界收窄

Siri 那一层只处理少量请求。Spotlight 模块负责索引和清除主题。小组件接收一份紧凑的快照。原生代码不会变成产品的第二个版本。

让原生改动可复现

扩展、entitlements、源文件和构建配置都应该写进 app 配置和 config plugin,而不是散落在没有记录的 Xcode 改动里。Prebuild 必须能重新生成整个项目。

把降级路径和二进制兼容当作功能的一部分

Apple Intelligence 并非随处可用。分享到社交平台的目标可能消失。图标可能下载失败。新的 JavaScript bundle 可能跑在一个缺少新模块的二进制上。

udictio 对可选集成做了保护,用 app 版本运行时策略来处理原生改动,并通过代码签名的 expo-updates 端点下发纯 JavaScript 修复,不强制在会话中途做一次打断式的 reload。

Expo 让这个规模变得可行

udictio 最初只是一个学术想法:让社交知识留存得更久。后来它成了一个生产环境的社交平台,有数千条内容,还有一个深入到 iOS 最新特性的移动应用。

Expo 不只是帮我快速起步。它让应用能持续扩张,而不至于裂成互不相干的 JavaScript 和原生两套代码库。

大部分平台层的工作由官方包处理。真正缺的部分用聚焦的 Swift 补上。Continuous Native Generation 让原生项目保持可复现,整个项目始终只有一个 Expo 工程作为唯一事实来源。

对我来说,Expo 不是在开发速度和原生能力之间的妥协。正是这套架构,让一个人也能构建并维护这个规模的移动产品。

Expo 可以成为一个深度原生应用的地基,而不会成为它的天花板。

来源: Expo Blog← 返回首页