tencent cloud

边缘安全加速平台 EO

步骤二:EdgeOne 边缘函数写入渠道信息到 APK 包

下载
聚焦模式
字号
最后更新时间: 2026-11-03 14:52:16
本文档已由 AI 辅助审校
通过 EdgeOne 边缘函数,我们可以动态地将渠道信息写入到 APK 包内。用户只需访问与边缘函数绑定的域名并进行触发配置,就可以触发该边缘函数,从而实现 APK 的动态打包和加速分发。

步骤1:添加用于加速分发的加速域名

请根据 添加加速域名 指引添加加速域名,例如:www.example.com 且源站配置为 Android APK 母包 所在的对象存储 COS 自定义域名,详情可参见 存储桶切换自定义域名 。配置如下图所示:



说明:
该域名将用于访问下载 APK 安装包。

步骤2:创建用于触发渠道信息写入的边缘函数

1. 根据 函数管理 指引创建一个边缘函数,将如下代码复制到函数代码内。
说明:
本函数已更新,新增对单 Range 请求的支持,适配多线程下载器、断点续传等分段下载场景。此前 Range 请求会直接透传回源、不写入渠道信息,更新后即可正常写入。
支持 bytes=start-end、bytes=start-、bytes=-suffix 三种单 Range 格式:请求范围与渠道信息写入区重叠时仍会正确写入渠道信息,并返回 206 Partial Content 及正确的 Content-Length、Content-Range;无法满足的 Range 请求返回 416 Range Not Satisfiable。
非 Range 请求的响应会携带 Accept-Ranges: bytes,便于下载器识别并启用分段下载。
暂不支持 Multi-Range(如 bytes=0-99,200-299):此类请求将直接透传回源,渠道信息不会被写入。常见下载器的多线程分片为多个并发的单 Range 请求,不受该限制影响。
const CUSTOM_BLOCK_VALUE_LENGTH = 10240;
const APK_SIGNING_BLOCK_MAGIC_LENGTH = 16;
const APK_SIGNING_BLOCK_OFFSET_LENGTH = 8;

const APK_COMMENT_LENGTH = 512;

class EdgePack {
totalSize;
signVersion;
centralDirectoryOffset;
customBlockValueStart;
customBlockValueEnd;
rangeRelativeOffset;
customInfo;

constructor() {
/** @type {number} */
this.totalSize = null;
this.signVersion = null;
this.centralDirectoryOffset = null;
this.customBlockValueStart = null;
this.customBlockValueEnd = null;
this.rangeRelativeOffset = null;
/** @type {string} */
this.customInfo = null;
/** @type {string} */
this.customName = null;
/** @type {number[]} */
this.customRange = null;
/** @type {boolean} */
this.invalidRange = false; // 标记是否为非法 Range
}

async handle(event) {
const { request } = event;

const headers = new Headers(request.headers);

const modifiedRequest = new Request(request, { headers });

if (!this.checkRequest(modifiedRequest)) {
// 如果检测到非法 Range 格式,直接返回 416
if (this.invalidRange) {
const responseHeaders = new Headers();
// 此时还未获取到 totalSize,无法设置 Content-Range
// responseHeaders.set("Content-Range", `bytes */0`);
return event.respondWith(
new Response("", {
status: 416,
statusText: "Range Not Satisfiable",
headers: responseHeaders,
})
);
}
// 其他情况 passthrough
return;
}

let response = null;
try {
const headHeaders = new Headers(modifiedRequest.headers);
// 删除 Range 头,获取正确的 Content-Length
headHeaders.delete("Range");

const headRequest = new Request(modifiedRequest.url, {
method: "HEAD",
headers: headHeaders,
});
response = await fetch(headRequest, { redirect: "manual" });
} catch (err) {
const error = {
code: "FETCH_ORIGIN_ERROR",
message: err?.message,
};
response = new Response(JSON.stringify(error), {
status: 590,
});
}

if (!this.checkResponse(response)) {
// 如果是非法 Range(格式错误或范围错误),返回 416 状态码
if (this.invalidRange && this.totalSize !== null) {
const headers = new Headers();
headers.set("Content-Range", `bytes */${this.totalSize}`);
return event.respondWith(
new Response("", {
status: 416,
statusText: "Range Not Satisfiable",
headers: headers,
})
);
}
return event.respondWith(response);
}

if (!!this.customName) {
response.headers.set(
"Content-Disposition",
`attachment; filename="${this.customName}"`
);
}

// 处理单一范围的 Range 请求
if (this.customRange) {
const [rangeStart, rangeEnd] = this.customRange;

let actualStart = rangeStart;
let actualEnd = rangeEnd;
let contentLength = this.totalSize;

if (rangeStart === null && rangeEnd !== null) {
// bytes=-suffix 格式:最后 suffix 个字节
actualStart = Math.max(0, this.totalSize - rangeEnd);
actualEnd = this.totalSize - 1;
contentLength = Math.min(rangeEnd, this.totalSize);
} else if (rangeEnd !== null) {
// bytes=start-end 格式
contentLength = rangeEnd - rangeStart + 1;
actualEnd = rangeEnd;
} else if (rangeStart !== null) {
// bytes=start- 格式
actualEnd = this.totalSize - 1;
contentLength = this.totalSize - rangeStart;
}

response.headers.set(
"Content-Range",
`bytes ${actualStart}-${actualEnd}/${this.totalSize}`
);
response.headers.set("Content-Length", contentLength.toString());
response.headers.set("Accept-Ranges", "bytes");
response = new Response(null, {
status: 206,
statusText: "Partial Content",
headers: response.headers,
});
}
// 处理非 Range 请求
else {
// 非 Range 请求也应该声明支持 Range
response.headers.set("Accept-Ranges", "bytes");
}

const streamResponse = new Response(
await this.combineStreams(modifiedRequest),
response
);

return event.respondWith(streamResponse);
}

getRelativeOffset(response) {
const start = this.customBlockValueStart;
const end = this.customBlockValueEnd;

const range = response.headers.get("Content-Range");

if (!range) return start;

const match = range.match(/bytes\\s*(\\d*)-(\\d*)/i);
if (!match || match?.length < 2) {
return start;
}

const rangeStart = +match[1];
const rangeEnd = +match[2];

if (rangeEnd < start || rangeStart > end) {
return null;
}

return start - rangeStart;
}

checkRequest(request) {
if (request.method !== "GET") {
// 非 GET 请求,使用 passthrough 回源
return false;
}

if (request.headers.has("Range")) {
const range = request.headers.get("Range");

// 检查是否为多 Range 请求
if (range.includes(",")) {
// Multi-Range 请求,使用 passthrough 回源
console.log("Multi-Range detected, using passthrough");
return false;
}
// 单 Range 请求
else {
// 使用更精确的正则:分别匹配 bytes=start-end 和 bytes=-suffix
// 捕获组: (1)start (2)end 用于 start-end 格式
// (3)suffix 用于 -suffix 格式
const match = range.match(/bytes=(?:(\\d+)-(\\d*)|-(\\d+))$/i);

if (match) {
// 处理三种 Range 格式:
// 1. bytes=start-end: 指定范围
// 2. bytes=start-: 从 start 到文件末尾
// 3. bytes=-suffix: 最后 suffix 个字节

// 检查是否为 bytes=-suffix 格式(第3个捕获组有值)
if (match[3] !== undefined) {
// bytes=-suffix 格式:最后 suffix 个字节
const suffix = +match[3];

// bytes=-0 是无效的范围请求,应该返回 416
if (suffix === 0) {
console.warn("Invalid Range: bytes=-0 is not satisfiable");
this.invalidRange = true;
return false;
}

this.customRange = [null, suffix];
} else {
// bytes=start-end 或 bytes=start- 格式
const start = match[1] ? +match[1] : null;
const end = match[2] && match[2] !== "" ? +match[2] : null;

// 检测 start > end 的非法情况
if (start !== null && end !== null && start > end) {
console.warn("Invalid Range: start > end:", range);
this.invalidRange = true;
return false;
}

this.customRange = [start, end];
}
} else {
console.warn("Invalid Range format:", range);
this.invalidRange = true;
return false;
}
}
}

const { pathname, searchParams } = new URL(request.url);

// 获取渠道信息参数,存放在 comment 参数中
const comment = searchParams?.get("comment");

// 非 APK 文件,或是没有渠道信息参数,使用 passthrough 回源
if (!pathname.endsWith(".apk") || !comment) {
return false;
}

this.customInfo = comment;

const filename = searchParams?.get("filename");

if (filename) {
this.customName = filename;
}

return true;
}

/**
* @param {number} fileSize
* @returns {boolean}
*/
validateRange(fileSize) {
// 验证单 Range
if (!this.customRange) {
return true;
}

const [rangeStart, rangeEnd] = this.customRange;

// 处理 bytes=-suffix 格式
if (rangeStart === null && rangeEnd !== null) {
// suffix 超出文件大小,修正为文件大小
if (rangeEnd > fileSize) {
this.customRange = [null, fileSize];
return true;
}
return true;
}

// 处理 bytes=start-end 和 bytes=start- 格式
if (rangeStart !== null) {
// start 超出文件大小范围,标记为非法并返回 false
if (rangeStart >= fileSize) {
this.invalidRange = true;
return false;
}

// start 在范围内,但 end 超出范围,修正为 start-
if (rangeEnd !== null && rangeEnd >= fileSize) {
this.customRange = [rangeStart, null];
return true;
}
}

return true;
}

checkResponse(response) {
if (response.status !== 200 && response.status !== 206) {
return false;
}

const contentLength = response.headers.get("Content-Length");

if (response.body === null || contentLength === null) {
return false;
}

this.totalSize = Number(contentLength);

if (this.customRange && !this.validateRange(this.totalSize)) {
// Range 请求超出范围,标记 invalidRange 并返回 false
// invalidRange 已在 validateRange() 中设置
return false;
}

const cosOffsetHeader = response.headers.get("x-cos-meta-edgepack-offset");
const cosTypeHeader = response.headers.get("x-cos-meta-edgepack-type");

if (!cosOffsetHeader || !cosTypeHeader) {
return false;
}

this.signVersion = cosTypeHeader;
this.centralDirectoryOffset = Number(cosOffsetHeader);

if (this.signVersion === "v1") {
this.customBlockValueStart = this.totalSize - APK_COMMENT_LENGTH;
this.customBlockValueEnd = this.totalSize;
} else {
this.customBlockValueStart =
this.centralDirectoryOffset -
CUSTOM_BLOCK_VALUE_LENGTH -
APK_SIGNING_BLOCK_MAGIC_LENGTH -
APK_SIGNING_BLOCK_OFFSET_LENGTH;
this.customBlockValueEnd = this.centralDirectoryOffset;
}

this.rangeRelativeOffset = this.getRelativeOffset(response);

if (this.rangeRelativeOffset === null) {
return false;
}

return true;
}

async combineStreams(request) {
// 处理单 Range 或完整请求
let contentLength = this.totalSize;

if (this.customRange) {
const [rangeStart, rangeEnd] = this.customRange;

if (rangeStart === null && rangeEnd !== null) {
// bytes=-suffix 格式:最后 suffix 个字节
contentLength = Math.min(rangeEnd, this.totalSize);
} else if (rangeEnd !== null) {
// bytes=start-end 格式
contentLength = rangeEnd - rangeStart + 1;
} else if (rangeStart !== null) {
// bytes=start- 格式
contentLength = this.totalSize - rangeStart;
}
}

const { readable, writable } = new FixedLengthStream(contentLength);
this.handleStream(request, writable);
return readable;
}

/**
* 写入指定范围的数据
* 使用 pipeTo 优化性能,适用于单 Range 请求
* @private
*/
async _writeRangeData(request, writable, actualStart, actualEnd) {
const comment = this.customInfo;
const encoder = new TextEncoder();
const section = encoder.encode(comment);

// Header 部分:签名块之前的数据
if (actualStart < this.customBlockValueStart) {
const apkHeader = await this.apkHeaderStream(
request,
actualStart,
actualEnd
);
if (apkHeader !== null) {
try {
await apkHeader.pipeTo(writable, {
preventClose: true,
});
} catch (e) {
console.error("HEADER_STREAM_ERROR: ", e);
}
}
}

// Body 部分:签名块(渠道信息注入区域)
if (
actualStart < this.customBlockValueEnd &&
(actualEnd === null || actualEnd >= this.customBlockValueStart)
) {
const apkBody = await this.apkBodyStream(
request,
section,
actualStart,
actualEnd
);
if (apkBody !== null) {
const apkBodyStream = apkBody.stream();
try {
await apkBodyStream.pipeTo(writable, {
preventClose: true,
});
} catch (e) {
console.error("BODY_STREAM_ERROR: ", e);
}
}
}

// Tail 部分:签名块之后的数据
if (actualEnd === null || actualEnd >= this.customBlockValueEnd) {
const apkTail = await this.apkTailStream(
request,
actualStart,
actualEnd
);
if (apkTail !== null) {
try {
await apkTail.pipeTo(writable, {
preventClose: true,
});
} catch (e) {
console.error("TAIL_STREAM_ERROR: ", e);
}
}
}
}

async handleStream(request, writable) {
try {
const [rangeStart, rangeEnd] = this.customRange || [0, null];

let actualStart = rangeStart;
let actualEnd = rangeEnd;

if (rangeStart === null && rangeEnd !== null) {
actualStart = Math.max(0, this.totalSize - rangeEnd);
actualEnd = this.totalSize - 1;
}

await this._writeRangeData(request, writable, actualStart, actualEnd);
} catch (err) {
console.error("HANDLE_STREAM_ERROR: ", err);
} finally {
let writer = writable.getWriter();
writer.close();
writer.releaseLock();
}
}

async apkHeaderStream(request, actualStart, actualEnd) {
const headers = new Headers(request.headers);

let headerStart = 0;
let headerEnd = this.customBlockValueStart - 1;

if (actualStart > 0) {
headerStart = Math.max(headerStart, actualStart);
}
if (actualEnd !== null) {
headerEnd = Math.min(headerEnd, actualEnd);
}

if (headerStart > headerEnd) {
return null;
}

headers.set("Range", `bytes=${headerStart}-${headerEnd}`);

// 获取签名块之前的部分
const headResponse = await fetch(request, {
headers: headers,
});

return headResponse.body;
}

async apkBodyStream(
request,
section = null,
actualStart = 0,
actualEnd = null
) {
const headers = new Headers(request.headers);

let bodyStart = this.customBlockValueStart;
let bodyEnd = this.customBlockValueEnd - 1;

if (actualStart > this.customBlockValueStart) {
bodyStart = Math.max(bodyStart, actualStart);
}
if (actualEnd !== null) {
bodyEnd = Math.min(bodyEnd, actualEnd);
}

if (bodyStart > bodyEnd) {
return null;
}

headers.set("Range", `bytes=${bodyStart}-${bodyEnd}`);

const middleResponse = await fetch(request, {
headers: headers,
});

const reader = middleResponse.body.getReader();

let outputBuffers = [];
try {
let handledBytes = bodyStart;
while (true) {
const result = await reader.read();

if (result.done) {
console.log("APK_BODY_STREAM_DONE");
break;
}

const startByteOffset = handledBytes;
const buffer = result.value;
handledBytes += buffer.byteLength;

// 渠道信息在签名块中的绝对位置
const channelAbsoluteStart = this.customBlockValueStart;
const channelAbsoluteEnd = channelAbsoluteStart + section.byteLength;

// 计算当前 buffer 中需要替换的范围
const min = Math.max(startByteOffset, channelAbsoluteStart);
const max = Math.min(channelAbsoluteEnd, handledBytes);

if (min < max) {
const bufferStart = min - startByteOffset;
const sectionStart = min - channelAbsoluteStart;
const sectionEnd = max - channelAbsoluteStart;

const replacement = section.subarray(sectionStart, sectionEnd);

new Uint8Array(buffer).set(replacement, bufferStart);
}

outputBuffers.push(buffer);
}
} catch (err) {
console.error("APK_BODY_STREAM_ERROR: ", err);
}
return new Blob(outputBuffers);
}

async apkTailStream(request, actualStart, actualEnd) {
const headers = new Headers(request.headers);

let tailStart = this.customBlockValueEnd;
let tailEnd = this.totalSize - 1;

if (actualStart > this.customBlockValueEnd) {
tailStart = Math.max(tailStart, actualStart);
}
if (actualEnd !== null) {
tailEnd = Math.min(tailEnd, actualEnd);
}

if (tailStart > tailEnd) {
return null;
}

headers.set("Range", `bytes=${tailStart}-${tailEnd}`);

const tailResponse = await fetch(request, {
headers: headers,
});

return tailResponse.body;
}
}

async function handleEvent(event) {
const edgepack = new EdgePack();
await edgepack.handle(event);
}

addEventListener("fetch", handleEvent);
2. 完成部署函数后,根据 函数管理 指引配置触发规则,其 HOST 值为 步骤1 创建的加速域名,如下所示:



3. 单击确定,即可完成触发规则的创建。用户访问域名 www.example.com 且文件后缀为.apk时,即可触发边缘函数进行动态打包。

步骤3:验证 EdgeOne 边缘函数是否已将渠道信息写入到 Android APK 包

在浏览器中输入带有渠道信息的 URL,例如: http://www.example.com/v2_src.apk?comment=test 即可触发边缘函数,动态地将渠道信息注入到指定位置,其中 comment 为您在 创建用于触发渠道信息写入的边缘函数 中定义的渠道参数。以 v2-VasDolly 方式为例,可以使用 VasDolly 多渠道打包工具来读取动态注入的渠道信息:








帮助和支持

本页内容是否解决了您的问题?

填写满意度调查问卷,共创更好文档体验。

文档反馈