织流Aria下载器诞生记:Free Download Manager × 鸿蒙PC上架实战
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_FDM
把经典开源下载器 Free Download Manager 的能力带到鸿蒙 PC,只是上半场;让它通过审核、出现在每个用户的「应用市场」里,才是真正的下半场。本文以织流Aria下载器(Free Download Manager 的鸿蒙 PC 移植:下载核心复用 aria2 1.37.0 引擎,界面为 ArkTS 原生重写)为例,前半部分用真实源码讲清这个上架包里到底装了什么、为什么这样设计能顺利过审;后半部分完整复盘从 Release 签名、构建
.app发布包、填写开发者中心资料、提交审核到成功上架的全过程,文末附踩坑清单。

环境信息:
| 项 | 值 |
|---|---|
| IDE | DevEco Studio(HarmonyOS SDK 6.0.1(21)) |
| 目标设备 | HarmonyOS PC(2in1)/ 平板 |
| 包名 | com.zhiliu.aria2 |
| 上架版本 | 1.1.0(versionCode 1001000) |
| 上架包 | .app,5.45 MB(云加固后) |
| 应用权限 | 仅 ohos.permission.INTERNET(normal 级) |
| 引擎 | aria2 1.37.0(C++ 静态链接 wslay + OpenSSL,aarch64) |
一、先看结果:应用市场里的「织流」
上架后的应用市场 PC 端页面长这样:

应用名、副标题「极速多线程,断点续传。」、分类(工具)、年龄分级(3+)、版本 1.1.0 (1001000)、更新日期 2026/10/20,全部来自开发者中心的资料配置。这个页面就是你的应用给所有用户的第一印象,值得在提交前反复打磨。
开发者中心后台的状态页:

状态「已上架」,上架时间 2026-10-27 13:47:56。注意一个细节:审核通过不等于零意见——这条记录里挂着审核附件「审核报告.zip」和一条审核意见:
您的应用"织流Aria下载器(from FDM)"已通过审核并成功上架,同时您的应用还存在一些问题需要您的关注和处理,详情请参考审核报告。
从这条意见里能读出三件事:
- 「已通过审核并成功上架」——核心门槛已过,应用正式进入市场分发通道;
- 「还存在一些问题需要关注」——过审是及格线,不是满分线,报告里的关注项是平台的免费质量外审;
- 「织流Aria下载器(from FDM)」——这是创建应用时在后台登记的应用名,市场展示名以应用信息配置为准,两者可以不同。
后面第九节会展开讲审核意见怎么消化。先把镜头拉回源码:为什么这个 5.45 MB 的包能如此顺利地通过审核?答案大半写在代码里。
二、这个包里装了什么:T1 混合移植路线
2.1 为什么选 T1
Free Download Manager(FDM)是 Windows/macOS/Linux 上老牌的开源多协议下载管理器,把它的核心能力——HTTP/HTTPS/FTP、BitTorrent 多连接分块下载与断点续传——带到鸿蒙 PC,就是「织流Aria下载器」这个项目的起点。移植已有桌面软件到 HarmonyOS,按源码复用方式通常分三档:
| 等级 | 做法 | 特征 |
|---|---|---|
| T0 | UI + 业务全部 ArkTS 重写 | 体验最佳,但协议栈重写等于重新踩所有坑 |
| T1 | ArkTS 原生 UI + C/C++ 核心交叉编译进包 | 逻辑复用不引入回归,界面完全原生 |
| T2 | Web/Electron/Qt 容器承载原应用 | 成本最低,但包体上百 MB、进程模型沉重 |
下载器属于「重协议、轻界面」类应用。选型上,FDM 的核心功能集(多协议、多连接分块、断点续传、BT)恰好被另一个开源引擎 aria2 完整覆盖——aria2 的多连接分块调度、BT DHT/PEX 是数十年社区验证的资产,不值得重写也不允许有偏差。所以最终路线:下载核心复用 aria2 引擎(T1 交叉编译进包),界面按 FDM 的使用习惯以 ArkTS 原生重写(T0 标准),不落 T2 容器。最终一个 HAP 装下引擎 + 全部界面,个位数 MB。
2.2 三层架构
┌─────────────────────────────────────────────────────────┐
│ ArkTS UI 层 │
│ Index.ets(主界面/任务列表/统计) About.ets(关于) │
│ ▲ 状态回写(onChanged 回调) │
│ │ 命令下发(startEngine/connect/addUri/pause…) │
│ DlController.ets(控制器:轮询、命令转发、日志) │
└────────┬────────────────────────────────┬───────────────┘
│ WebSocket (ws://127.0.0.1:6800/jsonrpc) │ NAPI
│ Aria2Rpc.ets ◄── JSON-RPC 2.0 ──► aria2 引擎 │
└─────────────────────────────────────────┼───────────────┘
│
┌─────────────────────────────────────────▼───────────────┐
│ NAPI 桥接层 libaria2spawn.so │
│ engine_spawn.cpp:posix_spawn 拉起 aria2c 子进程 │
│ engine_inproc.cpp:应用后台线程运行 aria2::main() │
└─────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────▼───────────────┐
│ aria2 引擎层 │
│ libaria2.a + libwslay.a + libssl.a/libcrypto.a │
│ (交叉编译自 aria2 1.37.0 源码,静态链接) │
│ 下载目录:<filesDir>/downloads/ 日志:<filesDir>/aria2.log │
└─────────────────────────────────────────────────────────┘
通信契约:无论引擎以子进程还是进程内方式运行,UI 层统一通过 WebSocket 连 127.0.0.1:6800 的 JSON-RPC 端点(/jsonrpc)收发指令与事件,上层对引擎运行模式无感知。这是刻意设计的解耦点——即使未来引擎运行方式再变,Aria2Rpc.ets 一层都不用改。
2.3 工程目录结构
ohos_AriaArkTS/
├── AppScope/
│ └── app.json5 # bundleName、版本号(上架关键)
├── entry/src/main/
│ ├── module.json5 # 设备类型、权限表(上架关键)
│ ├── cpp/ # ── native 层 ──
│ │ ├── CMakeLists.txt # 链接 aria2/wslay/openssl 静态库
│ │ ├── engine_spawn.cpp # posix_spawn 子进程管理(NAPI)
│ │ ├── engine_inproc.cpp # 进程内 aria2::main()(NAPI)
│ │ └── third_party/
│ │ ├── aria2/ # 交叉编译产物:main.o + libaria2.a + libwslay.a
│ │ └── openssl/ # libssl.a / libcrypto.a + 头文件
│ ├── ets/
│ │ ├── entryability/EntryAbility.ets
│ │ ├── model/
│ │ │ ├── Aria2Rpc.ets # JSON-RPC 2.0 WebSocket 客户端
│ │ │ ├── Aria2Task.ets # 任务模型
│ │ │ ├── EngineBootstrap.ets # 引擎双模式引导
│ │ │ └── FileExporter.ets # 沙箱文件 → 系统另存为(picker)
│ │ └── pages/
│ │ ├── Index.ets # 主界面(响应式布局)
│ │ ├── DlController.ets # 控制器
│ │ └── About.ets # 关于页
│ └── resources/rawfile/
│ └── aria2c # 随包分发的引擎二进制(aarch64)
├── build-profile.json5 # 签名配置(上架关键)
└── oh-package.json5
实际运行的界面:


2.4 为什么这个架构对上架审核天然友好
- 权限最小化:整个应用只申请一个 normal 级权限
INTERNET。文件导出不申请存储权限,走DocumentViewPicker.save()用户授权制。审核对权限清单非常敏感,能用 picker 解决的绝不申请敏感权限。 - 下载目录沙箱化:引擎写入被限制在应用私有目录(
/data/storage/el2/base/haps/entry/files/downloads/),不触碰公共目录。 - RPC 仅回环监听:
--rpc-listen-all=false,6800 端口只绑127.0.0.1,局域网不可达,RPC 密钥由用户自填、不硬编码。
这三点合起来就是一句上架经验:隐私与安全设计做得越干净,审核材料越好写,打回概率越低。
三、引擎层:aria2 是怎么钻进 5.45 MB 安装包的
3.1 交叉编译产物与静态链接
aria2 引擎按 aarch64-linux-ohos 目标交叉编译:OpenSSL 以 no-shared 方式编出静态库,wslay 同理,aria2 自身 --disable-shared --enable-static。产物连同 main.o(src/main.cc 的目标文件,进程内模式要用它里面的 aria2::main() 符号)一起放进 cpp/third_party/,由 CMake 链接进 NAPI 模块:
cmake_minimum_required(VERSION 3.10)
project(aria2spawn)
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
# aria2 需要 C++11+;NDK 默认 libc++ 与交叉编译 clang 同版本,ABI 兼容
set(CMAKE_CXX_STANDARD 14)
include_directories(
${NATIVERENDER_ROOT_PATH}
${NATIVERENDER_ROOT_PATH}/third_party/aria2/include # config.h
${NATIVERENDER_ROOT_PATH}/third_party/aria2/include/src # aria2 头文件
${NATIVERENDER_ROOT_PATH}/third_party/openssl/include)
add_library(aria2spawn SHARED
engine_spawn.cpp
engine_inproc.cpp)
target_link_libraries(aria2spawn PUBLIC
${NATIVERENDER_ROOT_PATH}/third_party/aria2/lib/main.o
${NATIVERENDER_ROOT_PATH}/third_party/aria2/lib/libaria2.a
${NATIVERENDER_ROOT_PATH}/third_party/aria2/lib/libwslay.a
${NATIVERENDER_ROOT_PATH}/third_party/openssl/lib/libssl.a
${NATIVERENDER_ROOT_PATH}/third_party/openssl/lib/libcrypto.a
libace_napi.z.so
z)
要点:静态库全量链接进 libaria2spawn.so,HAP 里只有这一个 so 承载全部下载协议栈,再加 rawfile 里的 aria2c 二进制作为子进程模式备份——上架包里没有任何动态依赖的第三方 so,这对审核时的包完整性与稳定性都是加分项。
3.2 NAPI 桥接:沙箱里怎么跑引擎
HarmonyOS 的 ArkTS 没有 child_process,而 aria2 的标准用法恰恰是命令行/daemon。桥接层用 NAPI 把 POSIX 能力暴露给 ArkTS,并且不做单点假设——同时提供子进程和进程内两条路:
模式 A:posix_spawn 拉起子进程(engine_spawn.cpp 摘录):
static napi_value StartEngine(napi_env env, napi_callback_info info) {
size_t argc = 2;
napi_value argv[2];
napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
std::string binary = GetStringArg(env, argv[0]);
// 确保可执行权限(rawfile 拷贝出来的文件默认 600)
chmod(binary.c_str(), 0755);
// 读取参数数组 → std::vector<std::string>
// ...(省略:napi_is_array / napi_get_array_length 遍历)
std::vector<char *> cArgv = BuildArgv(args);
pid_t pid = -1;
// 独立进程组,便于整组销毁
posix_spawnattr_t attr;
posix_spawnattr_init(&attr);
posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETPGROUP);
posix_spawnattr_setpgroup(&attr, 0);
int rc = posix_spawn(&pid, binary.c_str(), nullptr, &attr, cArgv.data(), environ);
posix_spawnattr_destroy(&attr);
napi_value result;
if (rc != 0 || pid < 0) {
// 返回 -errno,便于 ArkTS 侧诊断(-13=EACCES, -1=EPERM, -2=ENOENT)
napi_create_int32(env, -rc, &result);
return result;
}
napi_create_int32(env, static_cast<int32_t>(pid), &result);
return result;
}
细节一:chmod 0755 不能省——rawfile 拷贝出来的文件权限是 600,直接 spawn 会 ENOEXEC。细节二:失败时返回 -errno 而不是抛异常,让 ArkTS 侧能拿到 EACCES/EPERM 这类结构化信息做回退决策。
模式 B:进程内跑 aria2::main()(engine_inproc.cpp 摘录):
// aria2 内部入口(定义在 aria2 源码 src/main.cc,链接 libaria2.a 提供)
// 注意:必须先调 aria2::global::initConsole() 初始化 consoleCout/cerr,
// 否则 Logger 构造时 global::cout()->supportsColor() 会空指针崩溃
// (aria2 自己的 main() 入口也是这么做的)。
#include "error_code.h"
#include "console.h"
namespace aria2 {
error_code::Value main(int argc, char **argv);
}
static std::atomic<bool> g_running{false};
static std::thread *g_thread = nullptr;
static std::vector<std::string> g_args; // argv 字符串缓冲(线程生命周期内有效)
static std::vector<char *> g_argv;
static void RunEngineThread() {
if (!g_running) {
return;
}
try {
// 与 aria2 可执行文件的 main() 保持一致:先初始化 console 输出
aria2::global::initConsole(false);
aria2::main(static_cast<int>(g_argv.size()), g_argv.data());
} catch (...) {
// 引擎异常退出
}
g_running = false;
}
进程内模式有两个必须踩对的点:
initConsole(false)必须先调:aria2 内部日志对象依赖global::cout(),跳过这步会在 Logger 构造时空指针崩溃——这是把 daemon 代码嵌进进程里最容易漏的一句;- argv 生命周期:
std::vector<std::string> g_args持有字符串实体,g_argv只存c_str()指针。如果把string临时对象直接塞给char*数组,线程跑起来时悬垂指针已经生效。
NAPI 模块最终注册 6 个方法,ArkTS 侧 import spawn from 'libaria2spawn.so' 直接调用:
static napi_value Init(napi_env env, napi_value exports) {
napi_property_descriptor desc[] = {
{"startEngine", nullptr, StartEngine, nullptr, nullptr, nullptr, napi_default, nullptr},
{"stopEngine", nullptr, StopEngine, nullptr, nullptr, nullptr, napi_default, nullptr},
{"isEngineAlive", nullptr, IsEngineAlive, nullptr, nullptr, nullptr, napi_default, nullptr},
{"spawnErrorText", nullptr, SpawnErrorText, nullptr, nullptr, nullptr, napi_default, nullptr},
{"startEngineInProc", nullptr, NapiStartEngineInProc, nullptr, nullptr, nullptr, napi_default, nullptr},
{"isEngineInProcRunning", nullptr, NapiIsEngineInProcRunning, nullptr, nullptr, nullptr, napi_default, nullptr},
};
napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
return exports;
}
3.3 ArkTS 引导器:双模式启动策略
EngineBootstrap.ets 把「先试子进程、失败回退进程内」的策略收敛在一处:
export class EngineBootstrap {
enginePid: number = -1;
running: boolean = false;
lastError: string = '';
mode: string = ''; // 'spawn' | 'inproc'
/** 确保引擎二进制就位并启动;resolve(true) = 引擎已启动 */
start(): Promise<boolean> {
return new Promise<boolean>((resolve: (v: boolean) => void) => {
try {
const binPath = this.ensureBinary(); // rawfile 释放 aria2c(幂等)
if (binPath.length === 0) {
this.lastError = '引擎二进制准备失败';
resolve(false);
return;
}
const context = getContext() as common.UIAbilityContext;
const dlDir = `${context.filesDir}/downloads`;
this.ensureDir(dlDir);
const args: string[] = this.buildArgs(context, dlDir);
// ── 模式 A:子进程 ──
const pid: number = Number(spawn.startEngine(binPath, args));
if (pid > 0) {
this.enginePid = pid;
this.running = true;
this.mode = 'spawn';
resolve(true);
return;
}
const errText = String(spawn.spawnErrorText(pid));
this.log(`子进程拉起失败(errno ${-pid}: ${errText}),回退进程内引擎`);
// ── 模式 B:进程内(沙箱禁止 exec 时的兜底) ──
const rc: number = Number(spawn.startEngineInProc(args));
if (rc === 1 || rc === 0) { // 1=启动 0=已在运行
this.running = true;
this.mode = 'inproc';
resolve(true);
return;
}
this.lastError = '进程内引擎启动失败';
resolve(false);
} catch (e) {
this.lastError = `引擎启动异常: ${String(e)}`;
resolve(false);
}
});
}
}
传给引擎的参数表决定了上架应用的安全边界,每一项都有讲究:
private buildArgs(context: common.UIAbilityContext, dlDir: string): string[] {
const logFile = `${context.filesDir}/aria2.log`;
const args: string[] = [
'--enable-rpc',
`--rpc-listen-port=${ENGINE_PORT}`,
'--rpc-listen-all=false', // 仅回环监听,局域网不可达
'--rpc-allow-origin-all',
`--dir=${dlDir}`, // 下载目录锁在应用沙箱内
'--check-certificate=false',
'--continue=true', // 断点续传
'--max-concurrent-downloads=5', // 并发上限
'--split=8', // 单任务 8 连接分块
'--min-split-size=1M',
'--quiet=true',
// 诊断日志写文件,便于排查下载失败原因
`--log=${logFile}`,
'--log-level=debug'
];
return args;
}
--rpc-listen-all=false 和 --dir=<filesDir> 这两项,就是第六节 AGC 隐私声明里「引擎仅在本机回环通信、写入仅限应用私有目录」两句话的代码出处——隐私声明里的每句话都应该有代码背书。
3.4 RPC 客户端:纯 ArkTS 的 JSON-RPC over WebSocket
Aria2Rpc.ets 用系统 @ohos.net.webSocket 实现完整 JSON-RPC 2.0 客户端。核心是「自增 id 配对请求响应 + 按 method 名分发服务端推送」:
/** 调用 aria2 方法;token 自动注入到参数首位 */
call(method: string, params: Object[]): Promise<Object | undefined> {
return new Promise<Object | undefined>((resolve, reject) => {
if (this.ws === undefined || !this.connected) {
reject(new Error('未连接到 aria2'));
return;
}
const id = this.nextId++;
this.pending.set(id, { resolve, reject });
// token 注入参数首位(aria2 RPC 契约:第一参数固定为 token:<secret>)
const fullParams: Object[] = [`token:${this.secret}`];
for (const p of params) {
fullParams.push(p);
}
const req: RpcRequest = {
jsonrpc: '2.0',
id: `${id}`,
method,
params: fullParams
};
this.ws.send(JSON.stringify(req), (err: Error, ok: boolean) => {
if (!ok) {
this.pending.delete(id);
reject(new Error('发送失败'));
}
});
});
}
服务端推送的下载事件(无 id、有 method)走另一条分发路径:
private handleNotification(method: string, params: Object[] | undefined): void {
let gid = '';
if (params !== undefined && params.length > 0) {
const d = params[0] as Record<string, Object>;
gid = String(d['gid'] ?? '');
}
const ev = this.events;
if (ev === undefined) {
return;
}
switch (method) {
case 'aria2.onDownloadStart': ev.onStart?.(gid); break;
case 'aria2.onDownloadComplete': ev.onComplete?.(gid); break;
case 'aria2.onDownloadError': ev.onError?.(gid); break;
case 'aria2.onBtDownloadComplete':ev.onBtDownloadComplete?.(gid);break;
// ... pause / stop 同理
default: break;
}
}
连接成功后立即 aria2.getVersion 校验,并把版本与特性数显示在界面上(截图右上角「已连接」旁的引擎信息)。事件回调挂进控制器后,任务开始/完成/出错都会触发即时刷新,不用等下一个轮询周期。
3.5 数据面:1 秒轮询与任务签名
/** 拉取三类任务 + 全局统计 */
refreshOnce(): void {
const rpc = this.rpc;
if (rpc === undefined || !rpc.connected) {
return;
}
const keys = Aria2Task.KEYS;
Promise.all([
rpc.tellActive(keys),
rpc.tellWaiting(0, 100, keys),
rpc.tellStopped(0, 50, keys),
rpc.getGlobalStat()
]).then((results: (Object | undefined)[]) => {
const active = (results[0] ?? []) as Record<string, Object>[];
const waiting = (results[1] ?? []) as Record<string, Object>[];
const stopped = (results[2] ?? []) as Record<string, Object>[];
const stat = results[3];
this.tasks = [...active, ...waiting, ...stopped].map((d) => Aria2Task.fromDict(d));
if (stat !== undefined) {
this.stat = GlobalStat.fromDict(stat as Record<string, Object>);
}
this.refreshTick++;
// 任务集合发生变化才写状态行(避免每秒刷掉其他提示)
const sig = this.tasks.map((t: Aria2Task) => `${t.gid}:${t.status}`).join(',');
if (sig !== this.lastTaskSig) {
this.lastTaskSig = sig;
this.log(`任务更新: ${active.length} 下载中 / ${waiting.length} 排队 / ${stopped.length} 已停止`);
}
this.touch();
}).catch((e: Error) => {
this.log(`刷新失败: ${e.message}`);
});
}
两个设计点:Promise.all 把四类查询合并为一次批量更新,避免四个独立定时器竞态;refreshTick 自增后作为 ArkUI ForEach 的 key 参与渲染,绕开「ArkUI 同 key 不更新内容」的约束——这是把引擎数据变成 UI 状态的最后一公里。
四、UI 层与权限模型:为过审而做的架构决策
4.1 权限最小化
整个应用的权限表(module.json5):
{
"module": {
"name": "entry",
"type": "entry",
"mainElement": "EntryAbility",
"deviceTypes": ["2in1", "tablet"],
"deliveryWithInstall": true,
"installationFree": false,
"pages": "$profile:main_pages",
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"icon": "$media:app_icon",
"label": "$string:EntryAbility_label",
"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background",
"exported": true,
"skills": [
{
"entities": ["entity.system.home"],
"actions": ["action.system.home"]
}
]
}
],
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
]
}
}
只有一条 INTERNET(normal 级,安装即授予)。设备类型声明 ["2in1", "tablet"]——上架鸿蒙 PC 应用必须覆盖对应形态,这也决定了后面截图必须用 PC 窗口实拍。
4.2 文件导出:picker 用户授权制
下载目录在应用沙箱内,系统文件管理器不可见。把文件交还用户的正确姿势是系统「另存为」对话框——用户选定位置的那一刻,应用才获得对该文件的写入授权(FileExporter.ets 全码):
/**
* FileExporter — 把下载完成的文件导出到用户可见位置(系统「另存为」对话框)。
*
* aria2 的下载目录是应用私有沙箱(files/downloads/),系统文件管理看不到。
* 通过 DocumentViewPicker.save() 调出系统文件选择器,用户选目标目录后
* (如「下载」),把沙箱文件内容拷贝过去。
*/
import { common } from '@kit.AbilityKit';
import { picker } from '@kit.CoreFileKit';
import fs from '@ohos.file.fs';
export class FileExporter {
/**
* 导出沙箱文件到用户选择的位置。
* @param srcPath 沙箱内的源文件绝对路径
* @param fileName 建议文件名(显示在另存为对话框)
* @returns 成功返回用户选择的目标 URI;取消返回 '';失败抛错
*/
static async saveToUserDir(srcPath: string, fileName: string): Promise<string> {
const context = getContext() as common.UIAbilityContext;
const documentViewPicker = new picker.DocumentViewPicker(context);
const saveOptions = new picker.DocumentSaveOptions();
saveOptions.newFileNames = [fileName];
const uris = await documentViewPicker.save(saveOptions);
if (uris === undefined || uris.length === 0) {
return ''; // 用户取消
}
const destUri = uris[0];
// 打开目标文件写入,拷贝源文件内容
const srcStat = fs.statSync(srcPath);
if (srcStat === undefined) {
throw new Error('源文件不存在');
}
const srcFile = fs.openSync(srcPath, fs.OpenMode.READ_ONLY);
const destFile = fs.openSync(destUri, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
try {
const bufSize = 1024 * 1024; // 1MB 分块拷贝
const buf = new ArrayBuffer(bufSize);
let copied = 0;
while (copied < srcStat.size) {
const readSize = Math.min(bufSize, srcStat.size - copied);
const read = fs.readSync(srcFile.fd, buf, { offset: copied, length: readSize });
if (read <= 0) {
break;
}
fs.writeSync(destFile.fd, buf, { offset: copied, length: read });
copied += read;
}
fs.fsyncSync(destFile.fd);
} finally {
fs.closeSync(srcFile);
fs.closeSync(destFile);
}
return destUri;
}
}
对比一下另一种做法:申请 READ_WRITE_MEDIA 之类的存储权限直接写公共目录。picker 方案多写二十行代码,换来的是权限表里少一条敏感权限 + 隐私声明里少一段解释——上架语境下这笔交易稳赚。
4.3 PC 响应式布局:预计算缩放
鸿蒙 PC 是自由窗口形态,应用要跟着最大化/分屏重排。坑在于:ArkUI 属性链里调私有方法(如 .fontSize(this.calc()))会被静默忽略,所以所有缩放后的尺寸都预计算成 @State 字段:
/** 响应式缩放:随窗口宽度放大字号/间距/内容宽度(全屏不显小)。
* 注意:ArkUI 属性链里必须直接读 @State 字段(调私有方法会被静默忽略),
* 所以所有缩放后的尺寸都预计算成 @State。 */
@State winW: number = 600;
@State fs9: number = 9;
@State fs10: number = 10;
// ... fs11~fs16、hs8~hs32 同理
/** 依窗口宽度计算缩放档位并刷新全部预计算尺寸:
* <600 1.0 / 600-999 1.15 / >=1000 1.35 */
private applyScale(): void {
const w = this.winW > 0 ? this.winW : 600;
let s = 1;
if (w >= 1000) {
s = 1.35;
} else if (w >= 600) {
s = 1.15;
}
this.fs9 = Math.round(9 * s);
this.fs10 = Math.round(10 * s);
// ... 其余字段同理
}
窗口监听挂在 aboutToAppear 里:
// 响应式:取主窗口真实宽度并监听尺寸变化(最大化/分屏自动适配)
try {
const ctx = getContext() as common.UIAbilityContext;
const win = ctx.windowStage.getMainWindowSync();
this.winW = win.getWindowProperties().windowRect.width;
this.applyScale();
win.on('windowSizeChange', (size: window.Size) => {
this.winW = size.width;
this.applyScale();
});
} catch (e) {
this.winW = 600;
this.applyScale();
}
// 自动启动本地引擎并连接(2in1 本地模式)
this.ctl.startLocalEngine(this.secret);
这段也是上架自查清单里「PC 形态适配」的验收依据:最大化、还原、自由拖拽三种窗口状态下,界面字号、按钮、卡片都跟随缩放,不出现「手机字」。
五、上架前置三件事:账号、包名、版本号
5.1 开发者账号
注册华为开发者账号并完成实名认证,这是上架的入场券。个人开发者即可上架应用(无需企业资质),本文即以个人开发者身份完成。
5.2 包名:一次定终身
包名在创建应用时登记,发布 Profile 会与包名绑定,上架之后更换包名等于重新走一遍全流程。本项目定为 com.zhiliu.aria2——反域名风格、全小写、与应用一一对应,别用 com.example.demo 这种占位包名出包。
5.3 版本号:versionName 给人看,versionCode 给系统看
// AppScope/app.json5
{
"app": {
"bundleName": "com.zhiliu.aria2",
"vendor": "zhiliu",
"versionCode": 1001000,
"versionName": "1.1.0",
"icon": "$media:app_icon",
"label": "$string:app_name"
}
}
versionCode(1001000)是审核系统判断「新版本」的唯一依据,必须严格递增。本项目采用编码规则:
versionCode = 主版本 × 1000000 + 次版本 × 1000 + 修订号
1.1.0 → 1001000
1.1.1 → 1001001
1.2.0 → 1002000
预留了足够的升位空间,后续任意小版本都有递增余量,避免出现「改了代码却因 versionCode 不增而无法上传」的低级事故。
六、Release 签名三件套:把「调试签名」换成「上架签名」
开发期一直用的是 DevEco 自动签名(debug Profile),但上架必须用发布签名,两者是两套完全独立的材料:
| 调试签名(debug) | 发布签名(release) | |
|---|---|---|
| Profile 来源 | DevEco 一键自动生成 | 开发者中心手工申请 |
| 绑定对象 | 调试设备 + 包名 | 仅包名 |
| 安装方式 | hdc / IDE 直接安装 | 仅应用市场通道 |
| 用途 | 真机调试 | 上架发布 |
6.1 三件套的角色与申请
发布签名由三个文件组成,在开发者中心的证书管理页按顺序申请:
.p12密钥库:私钥容器。在 DevEco 的证书/密钥管理工具中生成,设定密码后妥善保存——丢了无法补发,只能重新生成并重新申请证书;.cer发布证书公钥:用.p12里的密钥在平台申请的证书文件,向平台证明「这个签名来自实名开发者」;.p7b发布 Profile:绑定具体包名(com.zhiliu.aria2)与权限声明(acls)的发布凭据,有效期注意管理,过期前记得续期。
6.2 签名配置
拿到三件套后填入工程签名配置:
// build-profile.json5(敏感字段已脱敏)
{
"app": {
"signingConfigs": [
{
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": ".../p12.p12",
"storePassword": "******(DevEco 加密存储,勿外传)",
"keyAlias": "go",
"keyPassword": "******",
"signAlg": "SHA256withECDSA",
"profile": ".../织流Aria下载器(from FDM)Release.p7b",
"certpath": ".../public.cer"
}
}
],
"products": [
{
"name": "default",
"signingConfig": "default",
"compatibleSdkVersion": "6.0.1(21)",
"runtimeOS": "HarmonyOS",
"targetSdkVersion": "6.0.1(21)"
}
],
"buildModeSet": [
{ "name": "debug" },
{ "name": "release" }
]
}
}
安全提醒:签名材料绝不能进版本库。仓库里把
.p12/.p7b/.cer一并排除(.gitignore追加*.p12、*.p7b、*.cer),密码只用 DevEco 加密存储——签名私钥泄露等于把应用的发布权拱手让人,任何人都能签出「看起来是你发布的」安装包。
6.3 一个必踩的坑:Release 包装不上真机
用发布签名出的包,hdc install 会直接报错:
9568322 install failed: not trusted app source
这不是配置错误——发布签名的包本来就不走 hdc 安装通道,它只认应用市场来源。所以正确的姿势是:本地测试装 debug 包,上架出 release 包,两套签名在 build-profile.json5 里按需切换(或建两个 product)。想明白这一点,就不会在 9568322 上浪费一个晚上。
七、构建 .app 发布包:从 HAP 到 APP
7.1 HAP 与 APP 的关系
.hap:单模块安装包,日常开发产物(assembleHap),用于 hdc 安装调试;.app:应用上架包(assembleApp),可含多个 HAP,是应用市场接受的唯一格式。
7.2 构建命令
DevEco 菜单:Build > Build App(s)/Hap(s) > Build App(s);命令行等价任务:
$env:DEVECO_SDK_HOME = "C:\Program Files\Huawei\DevEco Studio\sdk"
$env:JAVA_HOME = "C:\Program Files\Huawei\DevEco Studio\jbr"
$env:Path = "$env:JAVA_HOME\bin;" + $env:Path
hvigorw.bat --mode module -p module=entry@default -p product=default assembleApp --no-daemon
(JAVA_HOME 指向 DevEco 自带 jbr 是为了给打包工具提供 java 运行时,缺了会报 spawn java ENOENT。)
产物位于 entry/build/default/outputs/default/:
ohos_AriaArkTS-default-signed.app (1.1.0 (1001000),5.45 MB)
7.3 上传前的核对清单
- 构建模式为 release(debug 包上传同样会被通道拦截);
-
versionCode严格大于上一次上传值; - 包名为发布 Profile 绑定的
com.zhiliu.aria2; - 包体量级合理:本应用引擎 + 全部界面共 5.45 MB,得益于 T1 路线没有携带任何容器运行时——工具类应用,包体就是竞争力;
- 保持构建产物原样上传,加固由平台云加固完成(后台显示「软件包加固:加固」),不要自行预加固或二次压缩。
八、AGC 资料填写:让审核员 30 秒看懂你的应用
上架材料分两大块:应用信息(描述「这是谁」)与版本信息(描述「这版改了什么」)。
8.1 应用信息逐项
| 项 | 本应用填写 | 说明 |
|---|---|---|
| 应用名称 | 织流Aria下载器(from FDM) | 创建应用时登记,市场展示名以应用信息配置为准 |
| 图标 | 应用图标 | 避免与系统图标或知名应用混淆 |
| 简介/描述 | 痛点开头式文案 | 见下 |
| 分类 | 工具 | 与功能实质一致,别为了流量选热门分类 |
| 年龄分级 | 3+ | 按问卷如实作答:无社交、无支付、无 UGC |
| 应用截图 | PC 窗口实拍 ×3 | 见 8.3 |
| 隐私声明 | 可公开访问的 URL | 见 8.4 |
8.2 简介文案:把架构翻译成用户语言
本应用的简介写法是「痛点开头」:浏览器自带的下载器不支持多线程加速、断线就得从头再来,BT 种子和磁力链接无处安放;然后一句话讲清内核——aria2 1.37.0 引擎通过 C++ 交叉编译直接嵌入应用安装包,以 JSON-RPC over WebSocket 与界面层通信,不依赖任何外部命令行工具,一个 HAP 安装包即可运行,arm64-v8a 原生运行,性能与桌面端无异。
写简介的原则:每一句技术表述都必须在代码里真实存在。审核员可能逐句核对,用户会拿简介验收产品。
8.3 应用截图:用 PC 实拍,按使用动线排列
截图必须用 PC 窗口实拍(本文截图均为真机 2in1 窗口实拍),按「空状态 → 下载中 → 完成/导出」的使用动线排列,让审核员顺着截图就能脑补完整使用流程。手机截图凑数、带调试信息的开发截图都会成为扣分项。
8.4 隐私声明
需要提供可公开访问的隐私声明链接,说明数据收集范围。本应用仅申请 INTERNET、无账号体系、无遥测,声明可以写得很短,并且每句都有代码背书:引擎仅本机回环通信(--rpc-listen-all=false)、写入仅限应用私有目录(--dir=<filesDir>)、文件离开沙箱必须经用户在系统对话框中确认(picker)。又回到那句话:权限越少,隐私声明越好写。
8.5 版本信息与提交
- 更新说明:写用户能感知的变化(本版 1.1.0 的更新日期即市场页展示的 2026/10/20);
- 上传
.app包:上传后后台显示包名、版本号、大小并执行云加固; - 提交审核。
材料全部就绪后,后台流程条走完三步:填写应用信息 → 填写版本信息 (1.1.0) → 已提交。
九、审核与上架:时间线与审核意见处理
本项目的时间线:
| 时间(2026-10-27) | 事件 |
|---|---|
| 01:10:57 | 上传 .app 包(1.1.0 / 1001000 / 5.45 MB) |
| 13:47:56 | 状态变为「已上架」,附审核报告 |
审核速度受应用类型与材料质量影响,个人开发者通常在数日内完成。本次从上传安装包到上架在同一个自然日内完成,与两点直接相关:权限表只有一条 normal 级权限、隐私声明与功能截图材料完整自洽。
「过审 + 关注项」的组合在工具类应用里很常见,处理方式:
- 下载审核附件「审核报告.zip」,逐条对照建议;
- 关注项常见类别:UI 细节适配、隐私声明表述、权限说明文案;
- 可落地的条目排进下一版(versionCode 递增重新提审),不影响的记录在案即可。
过审不是终点,报告是免费的质量外审。
十、上架前自查:让应用自己证明自己没问题
提交审核前,在真机(HarmonyOS PC)上把核心链路完整过了一遍:添加链接 → 多线程下载 → 断点续传 → 完成 → 导出。这里安利一个自查利器——应用内引擎日志查看器(EngineBootstrap.readLog,读取沙箱内 aria2.log 末尾 80 行 / 64KB):
/** 读取引擎日志末尾若干行(应用内诊断) */
readLog(tail: number = 80): string[] {
try {
const context = getContext() as common.UIAbilityContext;
const logPath = `${context.filesDir}/aria2.log`;
if (!fs.accessSync(logPath)) {
return ['(引擎日志文件尚未生成)'];
}
const stat = fs.statSync(logPath);
if (stat === undefined || stat.size <= 0) {
return ['(引擎日志为空)'];
}
const file = fs.openSync(logPath, fs.OpenMode.READ_ONLY);
const buf = new ArrayBuffer(Math.min(stat.size, 64 * 1024)); // 最多读 64KB
const read = fs.readSync(file.fd, buf, { offset: Math.max(0, stat.size - 64 * 1024) });
fs.closeSync(file);
const decoder: util.TextDecoder = util.TextDecoder.create('utf-8');
const text: string = decoder.decodeWithStream(new Uint8Array(buf.slice(0, read)));
const lines = text.split('\n');
// 取末尾 tail 行非空行
const out: string[] = [];
for (let i = lines.length - 1; i >= 0 && out.length < tail; i--) {
if (lines[i].trim().length > 0) {
out.unshift(lines[i]);
}
}
return out.length > 0 ? out : ['(引擎日志为空)'];
} catch (e) {
return [`读取日志失败: ${String(e)}`];
}
}
实际效果:

日志里可见 aria2 引擎的标准心跳——AbstractCommand.cc 的 socket 读写轮询、WireDiskCache 的磁盘缓存更新,配合底部状态栏「1 下载中 / 0 排队 / 0 已停止」,整个引擎健康状况一屏可见。
这个功能对上架的意义在于:审核或用户报告问题时,不需要连电脑抓 hilog,用户随手截一张应用内日志就能定位。类似的自检功能(版本号展示、引擎运行模式提示——子进程/进程内一屏可见)成本极低,但对「审核报告关注项」的收敛非常有效。
十一、上架之后:版本管理、一键回滚与迭代
上架后的后台能力同样值得关注:
- 一键回滚:上架版本支持一键「回滚」撤回该版本——线上出严重问题时,先把市场版本退回上一个稳定版本,再从容修复;
- DevEco Testing 本地自查:后台明确提示可以在 DevEco Studio、DevEco Testing 等本地检测工具里自查,尽早发现应用隐私和体验问题——与其等审核报告点名,不如每次提审前自己先跑一遍;
- 版本升级:下一版只需
versionCode递增(1.1.1 → 1001001)、更新说明、重新出包上传,应用信息可以随版本滚动完善。
十二、踩坑清单(速查表)
| # | 坑 | 处理 |
|---|---|---|
| 1 | Release 签名包 hdc install 报 9568322(not trusted app source) | 正常现象,发布包只走应用市场通道;真机测试用 debug 包 |
| 2 | 发布 Profile 与包名强绑定 | 包名创建应用前定好,中途换包名 = 重走全流程 |
| 3 | versionCode 不递增导致无法上传新版本 | 采用「主×1000000 + 次×1000 + 修订」编码,天然递增 |
| 4 | 申请了一堆敏感权限,审核意见变长 | 能用 picker(用户授权制)解决的绝不申请权限;本应用仅 INTERNET |
| 5 | 误把 .hap 当上架包上传 | 上架传 .app(assembleApp 产物),且必须是 release 构建 |
| 6 | 自行加固/修改上传包导致校验异常 | 保持构建产物原样上传,加固由平台云加固完成 |
| 7 | 进程内跑 aria2 在 Logger 构造时空指针崩溃 | 先调 aria2::global::initConsole(false) 再进 aria2::main() |
| 8 | rawfile 拷出的引擎二进制无法执行 | 拷出后 chmod 0755(默认 600 权限) |
| 9 | 属性链调私有方法计算尺寸被静默忽略 | ArkUI 响应式全部预计算成 @State 字段 |
| 10 | 命令行构建报 spawn java ENOENT | JAVA_HOME 指向 DevEco 自带 jbr 并前置 PATH |
| 11 | 只看过审状态不读审核报告 | 报告里的关注项是免费质量外审,逐条消化进下一版 |
| 12 | 签名材料进版本库 | .p12/.p7b/.cer + 密码全部排除出仓库,私钥不落地 |
十三、常见问题(QA)
Q1:为什么不干脆用 T0 把下载协议栈全部重写?
不值得。aria2 的多连接分块调度、BT DHT/PEX、Metalink 解析是二十余年社区验证的资产,ArkTS 重写等于把所有协议坑重新踩一遍,而任何一处偏差都会直接表现为「下载失败」——这是用户感知最强的一类 bug。T1 复用核心 + 界面原生重写,把重写范围收敛到真正该重写的部分。
Q2:aria2 是 GPL-2.0,上架发布有什么要注意的?
GPL-2.0 不禁止上架分发,但有两个义务:一是分发时提供对应源码——本应用在关于页与仓库 README 中把引擎源码指向上游仓库(github.com/aria2/aria2);二是应用层代码的许可证要与引擎兼容——本项目应用层以 GPL-2.0-or-later 发布。上架前把许可证链理清楚,避免审核阶段或社区质疑时被动。
Q3:T1 路线的 C++ 门槛有多高?
比想象中低。本文的 NAPI 桥接层只有两个文件(engine_spawn.cpp / engine_inproc.cpp,合计约 300 行),都是「取参数、调 POSIX、还结果」的模式化代码;真正的工作量在交叉编译环境与运行时细节(initConsole、argv 生命周期、chmod 0755)。比 C++ 语法更重要的是「先试 A、失败自动回退 B」的防御性思路。
Q4:子进程模式和进程内模式,实际跑的是哪个?
都实现了,运行时自动选择。启动时先试 posix_spawn 子进程(隔离性好,引擎崩溃不连累应用),沙箱返回 EPERM/EACCES 则自动回退到进程内 aria2::main()。真机实测跑的是进程内模式,界面上有运行模式提示。两种模式对 UI 层完全透明——统一走 127.0.0.1:6800 的 JSON-RPC,切换逻辑收敛在 EngineBootstrap 一处。
Q5:引擎开着 6800 端口,安全吗?
三层防线:--rpc-listen-all=false 使端口只绑 127.0.0.1,局域网不可达;RPC 支持 secret 鉴权,密钥由用户自填、不硬编码;引擎下载目录被锁在应用私有沙箱内,文件离开沙箱必须经用户在系统对话框中确认。另外整个应用权限表只有 INTERNET 一条,没有后台常驻、没有悬浮窗。
Q6:上架时上传 .app 后的「加固」需要自己处理吗?
不需要。加固由平台在上传后自动完成(后台显示「软件包加固:加固」),保持构建产物原样上传即可;自行预加固或二次压缩反而可能导致校验异常。
Q7:断点续传是应用自己实现的吗?
不是。aria2 引擎原生支持:--continue=true 打开断点续传,进度由 .aria2 控制文件记录;暂停/恢复对应 RPC 的 aria2.pause / aria2.unpause。应用层只负责把命令映射到界面按钮,没有自己实现任何分块管理——这也是 T1 的意义:协议行为交给引擎,界面只做翻译。
Q8:BT / 磁力链接的实际体验如何?
HTTP/HTTPS 链路实测稳定(37MB 文件 5.6 MB/s 多线程下载)。BT/磁力由 aria2 内置引擎支持,但 DHT 发现与做种连接依赖网络环境——企业网、严格 NAT 下可能出现连接数为 0 的情况,属于上游引擎行为而非应用缺陷,BT 功能建议在普通网络环境使用。
Q9:个人开发者可以上架鸿蒙 PC 应用吗?
可以。本文即以个人开发者实名账号完成从提审到上架的全过程,无需企业资质;具体材料清单以开发者中心提交页的提示为准,权限越克制、隐私声明越完整,过审越顺。
十四、结语
回顾整个上架过程,可以浓缩成三句话:
- 架构决定审核难度:T1 混合路线让 5.45 MB 的包装下了完整 aria2 引擎,权限最小化 + 沙箱设计让隐私材料言简意赅——审核顺利的大头在写代码时就已经决定了;
- 签名是上架的第一道门:发布签名三件套(p12/cer/p7b)与调试签名完全独立,Release 包不进 hdc 通道,想明白这一点能少走很多弯路;
- 过审只是及格线:审核报告里的关注项、DevEco Testing 的自查结果、市场页的用户反馈,才是这个应用长期运营的日常输入。
如果你手上也有一把想在鸿蒙 PC 上落地的「老引擎」,希望这篇复盘能帮你把最后一公里——从构建产物到应用市场的那一格图标——走得更顺。
更多推荐


所有评论(0)