欢迎加入开源鸿蒙 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 发布包、填写开发者中心资料、提交审核到成功上架的全过程,文末附踩坑清单。

在这里插入图片描述

环境信息:

项值
IDEDevEco 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 端页面长这样:

应用市场中的织流Aria下载器

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

开发者中心后台的状态页:

开发者中心:已上架

状态「已上架」,上架时间 2026-10-27 13:47:56。注意一个细节:审核通过不等于零意见——这条记录里挂着审核附件「审核报告.zip」和一条审核意见:

您的应用"织流Aria下载器(from FDM)"已通过审核并成功上架,同时您的应用还存在一些问题需要您的关注和处理,详情请参考审核报告。

从这条意见里能读出三件事:

  1. 「已通过审核并成功上架」——核心门槛已过,应用正式进入市场分发通道;
  2. 「还存在一些问题需要关注」——过审是及格线,不是满分线,报告里的关注项是平台的免费质量外审;
  3. 「织流Aria下载器(from FDM)」——这是创建应用时在后台登记的应用名,市场展示名以应用信息配置为准,两者可以不同。

后面第九节会展开讲审核意见怎么消化。先把镜头拉回源码:为什么这个 5.45 MB 的包能如此顺利地通过审核?答案大半写在代码里。


二、这个包里装了什么:T1 混合移植路线

2.1 为什么选 T1

Free Download Manager(FDM)是 Windows/macOS/Linux 上老牌的开源多协议下载管理器,把它的核心能力——HTTP/HTTPS/FTP、BitTorrent 多连接分块下载与断点续传——带到鸿蒙 PC,就是「织流Aria下载器」这个项目的起点。移植已有桌面软件到 HarmonyOS,按源码复用方式通常分三档:

等级做法特征
T0UI + 业务全部 ArkTS 重写体验最佳,但协议栈重写等于重新踩所有坑
T1ArkTS 原生 UI + C/C++ 核心交叉编译进包逻辑复用不引入回归,界面完全原生
T2Web/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

实际运行的界面:

下载完成:37MB 文件 5.6 MB/s 多线程下载

空状态:引擎已在进程内运行,等待添加任务

2.4 为什么这个架构对上架审核天然友好

  1. 权限最小化:整个应用只申请一个 normal 级权限 INTERNET。文件导出不申请存储权限,走 DocumentViewPicker.save() 用户授权制。审核对权限清单非常敏感,能用 picker 解决的绝不申请敏感权限。
  2. 下载目录沙箱化:引擎写入被限制在应用私有目录(/data/storage/el2/base/haps/entry/files/downloads/),不触碰公共目录。
  3. 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 三件套的角色与申请

发布签名由三个文件组成,在开发者中心的证书管理页按顺序申请:

  1. .p12 密钥库:私钥容器。在 DevEco 的证书/密钥管理工具中生成,设定密码后妥善保存——丢了无法补发,只能重新生成并重新申请证书;
  2. .cer 发布证书公钥:用 .p12 里的密钥在平台申请的证书文件,向平台证明「这个签名来自实名开发者」;
  3. .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 级权限、隐私声明与功能截图材料完整自洽。

「过审 + 关注项」的组合在工具类应用里很常见,处理方式:

  1. 下载审核附件「审核报告.zip」,逐条对照建议;
  2. 关注项常见类别:UI 细节适配、隐私声明表述、权限说明文案;
  3. 可落地的条目排进下一版(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.log 末尾实时滚动

日志里可见 aria2 引擎的标准心跳——AbstractCommand.cc 的 socket 读写轮询、WireDiskCache 的磁盘缓存更新,配合底部状态栏「1 下载中 / 0 排队 / 0 已停止」,整个引擎健康状况一屏可见。

这个功能对上架的意义在于:审核或用户报告问题时,不需要连电脑抓 hilog,用户随手截一张应用内日志就能定位。类似的自检功能(版本号展示、引擎运行模式提示——子进程/进程内一屏可见)成本极低,但对「审核报告关注项」的收敛非常有效。


十一、上架之后:版本管理、一键回滚与迭代

上架后的后台能力同样值得关注:

  • 一键回滚:上架版本支持一键「回滚」撤回该版本——线上出严重问题时,先把市场版本退回上一个稳定版本,再从容修复;
  • DevEco Testing 本地自查:后台明确提示可以在 DevEco Studio、DevEco Testing 等本地检测工具里自查,尽早发现应用隐私和体验问题——与其等审核报告点名,不如每次提审前自己先跑一遍;
  • 版本升级:下一版只需 versionCode 递增(1.1.1 → 1001001)、更新说明、重新出包上传,应用信息可以随版本滚动完善。

十二、踩坑清单(速查表)

#坑处理
1Release 签名包 hdc install 报 9568322(not trusted app source)正常现象,发布包只走应用市场通道;真机测试用 debug 包
2发布 Profile 与包名强绑定包名创建应用前定好,中途换包名 = 重走全流程
3versionCode 不递增导致无法上传新版本采用「主×1000000 + 次×1000 + 修订」编码,天然递增
4申请了一堆敏感权限,审核意见变长能用 picker(用户授权制)解决的绝不申请权限;本应用仅 INTERNET
5误把 .hap 当上架包上传上架传 .app(assembleApp 产物),且必须是 release 构建
6自行加固/修改上传包导致校验异常保持构建产物原样上传,加固由平台云加固完成
7进程内跑 aria2 在 Logger 构造时空指针崩溃先调 aria2::global::initConsole(false) 再进 aria2::main()
8rawfile 拷出的引擎二进制无法执行拷出后 chmod 0755(默认 600 权限)
9属性链调私有方法计算尺寸被静默忽略ArkUI 响应式全部预计算成 @State 字段
10命令行构建报 spawn java ENOENTJAVA_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 应用吗?

可以。本文即以个人开发者实名账号完成从提审到上架的全过程,无需企业资质;具体材料清单以开发者中心提交页的提示为准,权限越克制、隐私声明越完整,过审越顺。


十四、结语

回顾整个上架过程,可以浓缩成三句话:

  1. 架构决定审核难度:T1 混合路线让 5.45 MB 的包装下了完整 aria2 引擎,权限最小化 + 沙箱设计让隐私材料言简意赅——审核顺利的大头在写代码时就已经决定了;
  2. 签名是上架的第一道门:发布签名三件套(p12/cer/p7b)与调试签名完全独立,Release 包不进 hdc 通道,想明白这一点能少走很多弯路;
  3. 过审只是及格线:审核报告里的关注项、DevEco Testing 的自查结果、市场页的用户反馈,才是这个应用长期运营的日常输入。

如果你手上也有一把想在鸿蒙 PC 上落地的「老引擎」,希望这篇复盘能帮你把最后一公里——从构建产物到应用市场的那一格图标——走得更顺。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐