↓ 跳过正文

React Native Expo OTA Updates

用 Django 和 Tigris 自建 Expo OTA 更新服务器

我用 Django REST Framework 和 Tigris S3 给自己的 React Native 应用搭了一套 OTA 更新服务,每月成本大约一美元。

用 Django 和 Tigris 自建 Expo OTA 更新服务器

2026 年 9 月 13 日更新。 这篇发出之后,Codemagic 发布了 Patch,一个 CodePush 路线的开源、可自托管 OTA 服务器。如果你是因为不想自己写服务器才找到这里的,它现在是一个实在的选项:我已经把一个应用从下面这套方案迁到了 Patch,并把过程写了下来,写在最后一节里也有一段简短说明。声明:那次迁移和那篇文章是 Codemagic 付费委托的,他们也希望在这里提一句。正文里的代码后来已精简为简短的示例。

我做的报价应用,bug 修复都是通过 OTA 推送的:JavaScript 改动不用等 App Store 审核,直接下发到用户手机上。Expo 官方的 EAS Update 本来就能做这件事,但我还是用 Django REST Framework 和 Tigris S3 自己搭了一套。从那以后,iOS 和 Android 的更新全都走这台服务器。

这篇文章把整个方案过一遍:数据模型、清单(manifest)接口、发布流水线,还有那些只有真正跑起来才会踩到的坑。

为什么要自己搭
#

对我来说主要是钱的问题。EAS Update 按用量计费,用户多了、更新频繁了,账单就跟着涨;而 Tigris 这类 S3 兼容存储几乎不要钱。当然还有别的理由:有些行业要求应用资产必须留在自己的基础设施里;服务器在自己手上,整个更新流程就都可控,想按用户群灰度、想做 bundle 的 A/B 测试都行。另外,更新链路也不再受 Expo 服务可用性或下一次调价的影响。

整体架构
#

一共四个部分:Django 后端负责下发更新清单、保存元数据;Tigris 存放 bundle 和资源文件本体;一个发布脚本负责导出、上传、登记更新;再加上移动应用本身,把更新地址指向我的服务器而不是 Expo 的。

┌─────────────────┐
│   Mobile App    │
│  (expo-updates) │
└────────┬────────┘
         │ 1. Request manifest
         │    (with headers: platform, runtime-version)
         ↓
┌─────────────────┐
│  Django Server  │
│  /api/expo-     │◄─── 2. Query DB for latest update
│   updates/      │
│   manifest/     │
└────────┬────────┘
         │ 3. Generate presigned URLs
         │
         ↓
┌─────────────────┐
│   Tigris S3     │
│  (Asset Files)  │◄─── 4. App downloads bundles directly
└─────────────────┘

具体实现
#

两个模型
#

一切都建立在 ExpoUpdate 和 ExpoUpdateAsset 之上。前者保存每次更新的元数据:

class ExpoUpdate(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    runtime_version = models.CharField(max_length=50, db_index=True)
    platform = models.CharField(
        max_length=10,
        choices=[("ios", "iOS"), ("android", "Android")],
        db_index=True
    )
    is_active = models.BooleanField(default=True, db_index=True)
    manifest_data = models.JSONField()
    description = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            models.Index(fields=["runtime_version", "platform", "is_active", "-created_at"])
        ]

这里有几个刻意的设计。runtime_version 对应 app.json 里的 runtimeVersion,客户端只会下载和自己运行时版本一致的更新,所以这个字段很关键。iOS 和 Android 的 bundle 不一样,所以分开存。is_active 是回滚开关:把有问题的更新停用,客户端就会退回上一个版本。manifest_data 直接把完整的 Expo Updates v1 协议清单存成 JSON,下发的时候取出来就行。

ExpoUpdateAsset 记录每个文件:

class ExpoUpdateAsset(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    update = models.ForeignKey(ExpoUpdate, on_delete=models.CASCADE, related_name="assets")
    hash = models.CharField(max_length=255, db_index=True)
    key = models.CharField(max_length=255)
    content_type = models.CharField(max_length=100)
    file_extension = models.CharField(max_length=10)
    file_path = models.CharField(max_length=500)
    file_size = models.IntegerField(default=0)

资源用 SHA-256 哈希来引用,天然不可变、好缓存,同一个资源可以被多次更新复用。

清单接口
#

应用和服务器真正打交道的地方是 /api/expo-updates/manifest/,实现的是 Expo Updates v1 协议:

@action(detail=False, methods=["get"], url_path="manifest")
def manifest(self, request):
    # Extract required headers
    protocol_version = request.META.get("HTTP_EXPO_PROTOCOL_VERSION")
    platform = request.META.get("HTTP_EXPO_PLATFORM")
    runtime_version = request.META.get("HTTP_EXPO_RUNTIME_VERSION")

    # Validate protocol version
    if protocol_version != "1":
        return Response(
            {"error": f"Unsupported protocol version: {protocol_version}"},
            status=400
        )

    # Find latest active update for this runtime + platform
    update = ExpoUpdate.objects.filter(
        runtime_version=runtime_version,
        platform=platform,
        is_active=True,
    ).order_by("-created_at").first()

    # No update available - client uses embedded bundle
    if not update:
        response = Response(status=204)
        response["expo-protocol-version"] = "1"
        return response

    # Generate presigned URLs for all assets
    manifest_data = build_manifest(update)  # fills in presigned asset URLs

    return Response(manifest_data, status=200)

有三个细节值得注意。没有更新时返回 204,应用会继续用内置的 bundle。清单里的资源地址是预签名 URL,应用直接从 Tigris 的 CDN 下载,不用整个流经 Django。响应头里的 expo-protocol-version 不能省,客户端真的会检查。

发布更新
#

发布靠一条 Django 管理命令,外面再包一层 shell 脚本。管理命令负责读取 expo export 的输出、给每个资源算 SHA-256 哈希、把 bundle 和资源并行传到 Tigris、写数据库记录,需要的话还会往桶里放一份用于生产同步的导入 JSON。精简到关键部分大致是这样:

from concurrent.futures import ThreadPoolExecutor

from django.db import transaction


def publish(platform, runtime_version, manifest, files):
    # Upload the bundle and assets in parallel (keys are content hashes)
    with ThreadPoolExecutor(max_workers=10) as executor:
        list(executor.map(upload_asset, files))

    # Swap the active update in one transaction
    with transaction.atomic():
        ExpoUpdate.objects.filter(
            runtime_version=runtime_version,
            platform=platform,
            is_active=True,
        ).update(is_active=False)

        return ExpoUpdate.objects.create(
            runtime_version=runtime_version,
            platform=platform,
            manifest_data=manifest,
        )

平时真正敲的是外面这层脚本:

# Publish to local environment
./publish.sh ios

# Publish to production
./publish.sh ios --production

# Dry run to validate
./publish.sh --dry-run

它会在导出前加载生产环境变量,平台可以只发一个也可以两个都发,还顺带处理下一节要讲的生产同步。

同步到生产环境
#

我不想把生产环境的凭证放在自己笔记本上,所以生产发布分两步:先在本地发布(资源传到 Tigris,同时生成一份元数据的 JSON 快照),再带着 S3 路径调用生产环境的 API,让服务器自己把元数据导进去:

@action(detail=False, methods=["post"], url_path="import-update")
def import_update(self, request):
    # Authenticate via Bearer token
    token = request.META.get("HTTP_AUTHORIZATION", "").removeprefix("Bearer ")
    if not hmac.compare_digest(token, settings.IMPORT_TOKEN):
        return Response({"error": "Invalid token"}, status=401)

    # Download the import JSON from the bucket
    s3_key = request.data.get("s3_key")
    obj = s3_client.get_object(Bucket=bucket_name, Key=s3_key)
    data = json.loads(obj["Body"].read())

    # Import into the production database
    with transaction.atomic():
        update, _ = ExpoUpdate.objects.update_or_create(id=data["id"], defaults={...})
        for asset_data in data["assets"]:
            ExpoUpdateAsset.objects.update_or_create(...)

    # Clean up the import JSON
    s3_client.delete_object(Bucket=bucket_name, Key=s3_key)
    return Response({"update_id": str(update.id)})

让应用指向自己的服务器
#

在 app.json 里配置更新地址和运行时版本:

{
  "expo": {
    "runtimeVersion": "1.0.0",
    "updates": {
      "url": "https://api.example.com/api/expo-updates/manifest/"
    }
  }
}

runtimeVersion 必须在应用和服务器之间严格对齐。改了原生代码或者升级了 Expo SDK,就把运行时版本加一,然后针对新版本重新发布更新。

Tigris 存储
#

Tigris 是 S3 兼容的对象存储,比 AWS S3 便宜得多,还自带全球边缘缓存。考虑到用户要下载好几 MB 的 bundle,这一点很实在。Django 侧的配置:

# settings.py
BUCKET_NAME = os.getenv("BUCKET_NAME")
AWS_ENDPOINT_URL_S3 = os.getenv("AWS_ENDPOINT_URL_S3")
AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY")
AWS_REGION = os.getenv("AWS_REGION", "auto")

客户端就是普通的 boto3:

import boto3

def create_s3_client(endpoint_url, region, access_key, secret_key):
    return boto3.client(
        "s3",
        endpoint_url=endpoint_url,
        region_name=region,
        aws_access_key_id=access_key,
        aws_secret_access_key=secret_key,
    )

下载走预签名 URL,应用直接从 CDN 拉取:

presigned_url = s3_client.generate_presigned_url(
    "get_object",
    Params={"Bucket": bucket_name, "Key": asset.file_path},
    ExpiresIn=3600,  # 1 hour
)

安全
#

清单接口是故意不做鉴权的,因为应用在用户登录之前就需要拿更新。导入接口则完全不同:它能写生产数据库,所以要求共享密钥,并用常数时间比较来防时序攻击:

IMPORT_TOKEN = os.getenv("IMPORT_TOKEN")

# Constant-time comparison prevents timing attacks
if not hmac.compare_digest(token, IMPORT_TOKEN):
    return Response({"error": "Invalid token"}, status=401)

资源完整性是这套设计白送的:所有资源都按 SHA-256 哈希校验,被篡改的资源直接失败。预签名 URL 一小时过期,bundle 也就不会被长期盗链。

性能
#

(runtime_version, platform, is_active, -created_at) 这个联合索引保证清单查询不会随着更新数量增长变慢:

class Meta:
    indexes = [
        models.Index(fields=["runtime_version", "platform", "is_active", "-created_at"])
    ]

发布脚本用线程池并行上传资源:

with ThreadPoolExecutor(max_workers=10) as executor:
    futures = {executor.submit(upload_asset, asset): asset for asset in assets}
    for future in as_completed(futures):
        # Track progress

以一次 50 个资源左右的典型更新为例,发布时间从大约 2 分钟缩到了 15 秒。分发这边什么都不用做,Tigris 会自动把资源缓存到离用户近的边缘节点。

日常流程
#

开发时
#

# 1. Make code changes in the app
git commit -am "Fix bug"

# 2. Publish OTA update to local environment
./publish.sh ios

# 3. Test on device
# App automatically downloads and applies update

发布到生产
#

# 1. Publish to production
./publish.sh ios --production

# 2. Monitor
# Check Django admin for update records
# Verify assets in Tigris dashboard

回滚
#

# Mark problematic update as inactive in Django admin
# or via management shell:
python manage.py shell

>>> from updates.models import ExpoUpdate
>>> bad_update = ExpoUpdate.objects.get(id="uuid-here")
>>> bad_update.is_active = False
>>> bad_update.save()

# Clients will now receive the previous active update

成本
#

这个应用大约有 500 个活跃用户。Tigris 上,约 200 MB 的历史更新每月大概 0.02 美元,更新下载带来的每月约 50 GB 流出流量大概 1 美元。Django 这边跑在原本就承载 API 的服务器上,没有额外成本。加起来每月一美元左右。同样的用量放到 EAS Update 上,一年大概要 300 到 500 美元,所以这套东西基本上立刻就回本了。

监控与调试
#

没什么花哨的。ViewSet 会把每个清单请求记进日志:

logger.info(f"Manifest request: platform={platform}, runtime={runtime_version}")

Django admin 就是事实上的控制台,把模型注册进去,查看、筛选都很方便:

@admin.register(ExpoUpdate)
class ExpoUpdateAdmin(admin.ModelAdmin):
    list_display = ["platform", "runtime_version", "is_active", "created_at"]
    list_filter = ["platform", "is_active", "runtime_version"]
    search_fields = ["description"]

客户端这边,expo-updates 会告诉你它看到了什么:

import * as Updates from 'expo-updates';

Updates.checkForUpdateAsync().then(update => {
  console.log('Update available:', update.isAvailable);
  console.log('Manifest:', update.manifest);
});

踩过的坑
#

运行时版本不匹配
#

最常见的故障也最安静:客户端只下载和自己运行时版本一致的更新。装机版本还在 1.0.0,你却按 1.0.1 发布,那就什么都收不到,也没有任何报错。运行时版本要和构建保持同步,只在原生代码变化时才递增。

createdAt 时间戳
#

expo-updates 客户端会拿清单的 createdAt 和内置 bundle 的 commitTime 比较,只有 createdAt 更新的才会被应用。ViewSet 里用数据库时间戳覆盖它:

manifest_data["createdAt"] = update.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ")

本地开发时,如果你在发布 OTA 之后又构建了二进制包,就重新发布一次 OTA,保证时间戳更新。

清单里的资源 key
#

清单中每个资源的 key 字段是 expo-updates 用来做缓存的,它必须是确定性哈希(比如文件名的 MD5),不能是随机 UUID 或随便一个字符串。搞错了,客户端可能无法正确缓存和读取资源,更新会在第一次成功加载之后悄悄失效。感谢读者 Raphael Mutschler 指出这个问题。在找到原因之前,他的更新恰好只成功过一次。

expoClient 里的应用配置
#

如果应用用到 Linking、Constants 或其他在运行时读取应用配置的东西,清单的 extra.expoClient 字段里就必须带上应用配置。少了它,应用第一次可能启动正常,关掉之后再打开就崩溃或干脆打不开。原因是 expo-updates 会用 OTA 的清单替换内置清单,expoClient 一缺,这些 API 依赖的配置就没了。这个坑也是 Raphael Mutschler 发现的。

清理旧资源
#

旧更新会在 Tigris 里越积越多。目前我还是手动清理:

# Delete updates older than 30 days
from datetime import timedelta
from django.utils import timezone

cutoff = timezone.now() - timedelta(days=30)
old_updates = ExpoUpdate.objects.filter(created_at__lt=cutoff, is_active=False)

for update in old_updates:
    # Delete assets from S3
    for asset in update.assets.all():
        s3_client.delete_object(Bucket=bucket_name, Key=asset.file_path)
    # Delete DB records
    update.delete()

按理说应该放进定时任务,我还没顾上。

接下来想加的
#

(2026 年 9 月:这三样 Patch 现在都有了,还不止,见迁移记录。)

灰度发布
#

加一个 rollout_percentage 字段,就能先放量给一部分用户:

rollout_percentage = models.IntegerField(default=100)

# In the manifest view:
if update.rollout_percentage < 100:
    # Hash user ID and check if they're in rollout group
    user_hash = int(hashlib.sha256(user_id.encode()).hexdigest(), 16)
    if (user_hash % 100) >= update.rollout_percentage:
        return Response(status=204)  # No update

区分预发环境
#

加一个 environment 字段,预发构建和生产就能收到不同的更新:

environment = models.CharField(max_length=20, default="production")

# Client sends environment in custom header
environment = request.META.get("HTTP_X_UPDATE_ENVIRONMENT", "production")
update = ExpoUpdate.objects.filter(environment=environment, ...).first()

下载统计
#

再加一个小模型,就能认真回答“这次更新到底有没有人收到”:

class ExpoUpdateDownload(models.Model):
    update = models.ForeignKey(ExpoUpdate, on_delete=models.CASCADE)
    user_id = models.CharField(max_length=255, null=True)
    platform = models.CharField(max_length=10)
    downloaded_at = models.DateTimeField(auto_now_add=True)

写在最后
#

整套系统就是两个 Django 模型、几个视图、一个发布脚本,外加每月一美元的基础设施。比我动手前预想的要小,而且从那以后一直安安静静地支撑着这个应用的两个平台。

上面的代码是从那个生产应用简化出来的示例,按自己的项目改改就能用。读者 Raphael Mutschler 还发布了一个独立实现 expo-ota-server,想从能直接跑的代码开始的话可以去看看。

如果你不想自己写服务器
#

这篇写完之后出现了两样东西,不用 Django 也能覆盖同样的地盘。第一个是上面提到的 Raphael 的 expo-ota-server。第二个是 Codemagic Patch:开源,通过 Docker Compose 安装跑在你自己的机器上,走的是 CodePush 协议而不是 Expo Updates 协议,所以你要把 expo-updates 换成它的 SDK,把 eas update 换成 cmpatch release-react。它能用 S3 兼容存储,所以本文这样的 Tigris 桶也照样能用。

它开箱就带着我在“接下来想加的”里列出来、却一直没做的那些:Staging 和 Production 两套 deployment、按百分比灰度、每个 release 的下载和安装数字、强制更新、二进制 patch、代码签名,以及新 bundle 在应用报告就绪之前崩溃时设备端的回滚。代价是,在任何人收到 Patch 更新之前先要发一个新的原生二进制,因为 SDK 得进到应用里;还有一台要跑的服务器,取代了一条管理命令。

我把自己的一个应用,连同已提交的原生工程和真实的发布流水线,从本文这套方案迁到了 Patch。那篇记录写了哪些顺利、哪些不顺,以及到底有多难。Codemagic 赞助了那项工作,并对它做了一轮事实审阅,不是编辑审阅。


完整规范见 Expo Updates Protocol Specification。