刚开始做 APP 的时候用的是像蒲公英/Fir/第八区这种分发平台。这些平台有些缺点,一是不支持多包同时分发,如果同时进行多个需求,需要打包测试,就无法同时分发。也就是 iOS 同 Bundle ID 或者安卓同包名,只能分发一个包。第二就是这些平台对区块链 APP 并不友好,轻则下架 APP,重则 ban 账号。
1.0 分发
刚开始第一次弄分发服务比较简单,IPA/APK 上传到腾讯的 COS,后面使用 Cloudflare 的 R2。获取到公开链接之后,编写一个静态 HTML 分发到 Gitee / GitHub Pages。APK 直接下载就行,IPA 使用的是苹果的 itms-services 协议。
即上传一个 Plist 文件到云存储,或者 Git 仓库上也可以。Plist 文件最重要的两个属性是 Bundle ID 和下载 URL,然后获取 Plist 的公开链接地址。如果上传到 Git 上,就要使用 Git 的 raw 地址,注意这个地址一定要是 HTTPS 链接。
1
| <a href="itms-services://?action=download-manifest&url=https://betterbag.com/manifest.plist">安装 App</a>
|
如果在 Mac 上点击能弹出 APP 安装器,就说明能成功获取到 plist 文件内容。

而 APK 只要把下载链接放进去即可。
1
| <div><a href="https://pubxxxx.apk">Android 点击下载APK</a></div>
|
这样一个基本的 APP 分发服务就搭建好了。
这种服务的缺点就是更新的时候比较麻烦,需要手动上传包到云存储,然后再更新 GitHub 资源,等待一两分钟自动部署完成。
2.0 一键上传分发
后面就有想法至少把流程简化,只要一上传就自动分发好,再也不用手动更新 Git 仓库。
方案是全部基于 Cloudflare 的服务,Cloudflare 对个人开发者真是太友好了,Cloudflare YYDS!
简单介绍一下需要用到的 Cloudflare 服务。
Cloudflare R2
基于 AWS S3 的云储存,个人免费空间有 10GB!每个月有免费的百万次操作!能够获取公开链接下载,也能够使用 S3 的 API 进行操作。
Cloudflare Worker
能够使用脚本对 Cloudflare 的服务进行操作,例如对 R2 进行增删改查。也能够公开调用 API。
Cloudflare Pages
和 GitHub Pages 一样,部署静态资源。
以上 3 个服务组合在一起,便可达到一键使用 Worker API 上传到 R2,再通过 Pages 进行分发。
开始搭建
1. 开通 R2
要开通 R2 云存储,个人账号需要绑定信用卡,有免费使用额度,只做分发服务的话不用担心会扣款。就算 100M 左右一个包,也能分发个八九十个,何况可以做更新,删除旧的包。
2. 开通 R2 的公开链接访问
在设置 - R2.dev 子域。

3. 安装 Wrangler
在这之前先安装 Wrangler,这是 Cloudflare 的快速部署工具。
然后登录
登录成功后,可以使用命令看到当前登录的账号。
然后开始创建项目
4. 创建 Worker 项目
1
| npm create cloudflare@latest
|
输入项目名称,选择第一个 HelloWorld Worker,可选择是否使用 TypeScript,我用的是 TypeScript。
根据需求写 Worker API,有点像 Node.js 写 API。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134
| interface Env { appBucket: R2Bucket; }
export default {
async fetch( request: Request, env: Env, ctx: ExecutionContext ): Promise<Response> { const bucket = env.appBucket;
const url = new URL(request.url); const pathname = url.pathname if (pathname.length == 0) { return new Response('operation not found') } const key = pathname.slice(1); if (key == 'getObjectList') { if (request.method != "GET") { return new Response("Method not allow", { status: 405 }); } const list = await bucket.list({ include: ['customMetadata'] }) let resp = Response.json(list) resp.headers.set('Access-Control-Allow-Origin', '*') resp.headers.set('Access-Control-Allow-Methods', 'GET,HEAD,POST,OPTIONS') resp.headers.set('Access-Control-Max-Age', '86400') return resp } if (key == 'upload') { if (request.method != "PUT" && request.method != "POST") { return new Response("Method not allow", { status: 405 }); } const formData = await request.formData() const fileName = formData.get('fileName') if (fileName != null && typeof fileName == 'string') { const title = formData.get('title') const desc = formData.get('desc') if (typeof title == 'string' && typeof desc == 'string') { const obj = await bucket.put(fileName, formData.get('file'), { customMetadata: { title, desc, } }); let resp = Response.json({ code: 200, message : `Put ${fileName} successfully!`, data: obj }) resp.headers.set('Access-Control-Allow-Origin', '*') resp.headers.set('Access-Control-Allow-Methods', 'GET,HEAD,POST,OPTIONS') resp.headers.set('Access-Control-Max-Age', '86400') return resp } } return new Response('upload failed') } if (key == 'getObject') { if (request.method != "GET") { return new Response("Method not allow", { status: 405 }) } const params = url.searchParams const name = params.get('name') ?? '' const object = await bucket.get(name); if (object === null) { return new Response("Object Not Found", { status: 404 }) } const headers = new Headers() object.writeHttpMetadata(headers) headers.set("etag", object.httpEtag) let resp = new Response(object.body) resp.headers.set('Access-Control-Allow-Origin', '*') resp.headers.set('Access-Control-Allow-Methods', 'GET,HEAD,POST,OPTIONS') resp.headers.set('Access-Control-Max-Age', '86400') return resp } if (key == 'getObjectInfo') { if (request.method != "GET") { return new Response("Method not allow", { status: 405 }) } const params = url.searchParams const name = params.get('name') ?? '' const object = await bucket.get(name); if (object === null) { return new Response("Object Not Found", { status: 404 }) } let resp = Response.json({ code: 200, message: 'success', data: object }) resp.headers.set('Access-Control-Allow-Origin', '*') resp.headers.set('Access-Control-Allow-Methods', 'GET,HEAD,POST,OPTIONS') resp.headers.set('Access-Control-Max-Age', '86400') return resp } if (key == 'delete') {
const formData = await request.formData() const fileName = formData.get('fileName') as string
const object = await bucket.get(fileName); if (object === null) { return new Response("Object Not Found", { status: 404 }) }
await bucket.delete(fileName)
let resp = Response.json({ code: 200, message : `Delete ${fileName} successfully!`, }) resp.headers.set('Access-Control-Allow-Origin', '*') resp.headers.set('Access-Control-Allow-Methods', 'GET,HEAD,POST,OPTIONS') resp.headers.set('Access-Control-Max-Age', '86400') return resp } return new Response('operation not found') }, };
|
可以看到我在 Env 里定义了一个 Bucket,需要在配置文件 wrangler.toml 里配置。
1 2 3
| [[r2_buckets]] binding = "appBucket" bucket_name = "app-publish"
|
配置好绑定的变量名和操作的 R2 Bucket 名称。
5. 部署 Worker
直接使用 Wrangler 就可以部署。
需要在部署的 Worker 设置里绑定 R2。

部署好 Worker 之后,就可以通过 Worker 的公开 API 进行调用了。

注意!第 4、5 步是部署一个 Worker 项目,其实把 Worker 放进 Pages 项目更好,这样就只用维护一个项目。但是经过测试,放进 Pages 的 Worker 获取静态资源极其不稳定,创建了两个项目测试亦是如此。具体可参照 Cloudflare Pages - 高级模式
如要切换这种模式,修改 Pages 项目里的 wrangler.toml 文件中的:
1
| pages_build_output_dir = ".vercel/output/static"
|
改成
1
| pages_build_output_dir = "worker"
|
这样所有请求就会走 worker 文件里的 _worker.ts。和 Worker 项目里的不同的是,在其他 URL 的时候要返回静态资源:
1
| return env.ASSETS.fetch(request);
|
目前 worker 文件夹里的 _worker.ts 文件未生效。
6. 创建 Pages 项目
使用 Wrangler 创建 Website or web app。
我选择的是 Next.js 项目。
7. 解决 Worker API 上传最大 100M 的问题
R2 是基于 AWS S3 的,直接使用 AWS S3 的 API,安装 S3 SDK。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const client = new S3Client({ credentials: { accessKeyId: '', secretAccessKey: '' }, endpoint: '', region: 'auto' })
const tohex = (str: string) => { var hex, i;
var result = ""; for (i=0; i<str.length; i++) { hex = str.charCodeAt(i).toString(16); result += ("000"+hex).slice(-4); }
return result }
const s3UploadFile = async (file: File, title: string, desc: string, timestamp: number) => {
let key = file.name
if (timestamp > 0) { const type = key.slice(key.lastIndexOf('.')) key = key.slice(0, key.lastIndexOf('.')) key = `${key}_${timestamp}${type}` }
const titleStr = tohex(title) const descStr = tohex(desc)
const uploadCmd = new PutObjectCommand({ "Bucket" : 'app-publish', "Key" : key, "Body" : file, "Metadata" : { "title" : titleStr, "desc" : descStr } })
try { const resp = await client.send(uploadCmd) return resp } catch (err) { return null } }
export { s3UploadFile }
|
endpoint 就是使用 R2 设置中的 S3 API。
accessKeyId 和 secretAccessKey 需要在 R2 中创建一个 API Token,创建完填入即可。

如果不用上传 100M 以上的文件,可以不用第 7 步。
8. 部署
这样项目就部署完成啦。
项目源码上传到了我的 GitHub。