鸿蒙 PC 桌面端应用上架实战:ApE 质粒编辑器从 Electron 壳到 AGC 的双重驳回闭环

欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_ApE

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

ApE 质粒编辑器是一款用纯 JavaScript 从零实现的分子生物学桌面工具,支持 GenBank 文件读写、环形 / 线性双视图 SVG 渲染、50 种限制酶切位点分析、IUPAC 简并码序列搜索与特征交互编辑,通过 Electron 壳 + web_engine HAP 工程的方式落到鸿蒙 PC 桌面端。它的第一版功能代码在本地跑得非常顺,我以为照着已有的壳工程配置改一改就能提审,结果实际走了三轮驳回:第一轮卡在壳工程配置(名称、权限、包名与签名),第二轮卡在 AGC 判定为"功能异常"的四个交互问题,第三轮收到的反馈甚至和第二轮一字不差。

这篇复盘会把这三轮驳回的每一处现场、每一段真实代码和最终结论完整记录下来。本文只谈"上架"这件事——尤其是那个最容易被忽略的事实:在鸿蒙 PC 上跑通功能,距离过审还差一整条链路。 文中所有配置与代码片段都直接摘自工程真实文件,结论均经过真机打包、安装与反复构建验证,希望能帮同样走 Electron 壳方案上架鸿蒙桌面端的朋友少走弯路。

一、先认清 Electron 壳工程的"上架特殊性"

普通 ArkTS 原生应用上架,配置基本集中在一个 module.json5 里。Electron 壳方案不一样,它是双层结构:

  • Electron Web 层:main.js、renderer.js、业务引擎 JS,运行在 ArkWeb 引擎里,负责实际功能。
  • 鸿蒙 HAP 壳工程:electron 入口模块 + web_engine HAR 模块,负责窗口、文件、权限、打包签名。

AGC 审核时看到的是鸿蒙壳,不是你的 Electron 代码,所以绝大多数驳回点藏在壳工程配置里。核心四类:

上架维度涉及文件典型驳回原因
应用身份AppScope/app.json5、多处 string.json名称多语言不一致、bundleName 与签名不匹配
权限声明web_engine/src/main/module.json5壳模板默认带了一堆用不到的敏感权限
图标资源AppScope/resources/base/media/还是 Electron 默认图标
内容合规main.js、renderer.js、隐私弹窗残留调试菜单、缺开源 / 免责声明

我一开始是按"构建门 → 合规门 → 上架门"这三道门来组织的,被第二轮驳回之后才补上第四道:

  1. 构建门:assembleHap / assembleApp 能不能打出包——主要卡在签名材料与 bundleName 不匹配(第四节)。
  2. 合规门:打出来的包能不能过人工 / 自动审核——主要卡在权限冗余、默认名称与图标残留、缺隐私与开源声明(第二、三、五节)。
  3. 功能门:审核员在真机上逐项点核心交互,点了没反应就算功能异常(第六节,本文重点)。
  4. 上架门:AGC 后台元数据填得规不规范——应用介绍、一句话简介、隐私政策链接、应用截图(第八节)。

功能门是 Electron 壳应用特有的高危项。因为壳加载的是部署目录里的静态资源,渲染层与鸿蒙原生能力之间隔着一层 IPC,很多在 Windows 上正常工作的写法,到了鸿蒙壳里会静默失效——而审核员只会写一句"点击保存无反应"。

顺带说明一个目录事实:ApE 的 Electron 业务代码平铺在 web_engine/src/main/resources/resfile/resources/app 根目录下,一共 6 个文件(main.js、index.html、renderer.js、plasmid-engine.js、styles/ape.css、package.json),没有 js 子目录,也没有 shell-adapter.js。按 js/shell-adapter.js 这个路径去检索会得到零匹配,但不要据此判定"没做适配"——真正的适配逻辑在 web_engine 的 ets 层,业务层只负责界面与算法。

二、应用身份信息:名称散落四处,漏改一处就驳

上架前第一件事是把"Electron 示例壳"变成一个真正的应用。最容易翻车的是同一个名称散落在多个文件里。

AppScope/app.json5 是应用级身份,也是所有配置的源头,下面是本工程去掉注释后的真实内容:

{
  "app": {
    "bundleName": "com.wei.apeeditor",
    "vendor": "wei",
    "versionCode": 1000000,
    "versionName": "1.0.0",
    "icon": "$media:app_icon",
    "label": "$string:app_name",
    "configuration": "$profile:configuration",
    "multiAppMode": {
      "multiAppModeType": "multiInstance",
      "maxCount": 1
    }
  }
}

逐条说清几个要点:

  • vendor 必须从示例值 example 改成你在 AGC 后台登记的真实开发者名,本工程为 wei。这个字段很容易被误填成产品名,填成 “ApE” 反而是错的。
  • bundleName 一旦确定就和签名材料死死绑定(第四节详谈,这是我卡得最久的一坑)。
  • versionCode 是整数且每次提审必须递增,versionName 才是给用户看的字符串。首次上架从 1000000 起步是好习惯。
  • label 走资源引用,不直接写死名称,所以下一步要去查 string.json。

应用显示名的真值源有两套,而且它们互不同步:

// AppScope/resources/base/element/string.json
{
  "string": [
    {
      "name": "app_name",
      "value": "ApE 质粒编辑器"
    }
  ]
}
// electron/src/main/resources/zh_CN/element/string.json(节选)
{
  "name": "EntryAbility_label",
  "value": "ApE 质粒编辑器"
}

这里有一个我在第二轮才真正搞明白的判据:桌面图标名和最近任务列表左上角、运行态卡片显示的是 EntryAbility_label,不是 app_name。所以遇到"名称不一致"驳回时,别急着改代码,先做成一张"位置—当前值"表格,判断分歧到底在代码侧还是 AGC 后台提交文案侧。

EntryAbility_label 有 base / en_US / zh_CN 三份本地化资源,本地化优先级高于 base(设备语言命中 en_US 或 zh_CN 时会覆盖 base),改名字必须三份一起改,否则一切到英文环境又变回 “Electron”。本工程四处(app_name + 三套 EntryAbility_label)最终全部统一为 “ApE 质粒编辑器”,连空格都对齐了。

自查残留的做法是全仓穷举,而不是靠记忆:

# 全仓扫描是否还有 Electron 旧名称残留
Select-String -Path "AppScope\**\*.json*","electron\src\main\resources\**\*.json" -Pattern "Electron" -SimpleMatch

还有一个隐蔽落点:web_engine 的四个 adapter 里硬编码了 bundleName 初始值。以 ElectronAppAdapter.ets 为例(PopupWindowAdapter、PermissionManagerAdapter、AppWindowAdapter 同理):

// web_engine/src/main/ets/adapter/ElectronAppAdapter.ets
private bundleName: string = 'com.huawei.ohos_electron';

这一处要说清边界:运行时会被 bundleManager.getBundleInfoForSelfSync() 覆盖,所以它既不影响功能,也不是第四节那个构建失败的成因。它属于"源码观感"层面的历史初值,改了更干净,不改也不会被驳回——别把它当成必改项去反复折腾,我一开始就在这里白花时间排查过。

三、权限精简:从 26 项砍到 14 项

这是 AGC 最常驳回的一项。web_engine 作为通用引擎,module.json5 默认声明了一大堆权限,而 ApE 是个纯离线的质粒编辑器,相机、麦克风、定位、蓝牙这些一个都不需要。

判断标准很简单:应用功能清单里能不能对应上这个权限?对应不上就删。 更靠谱的做法是别拍脑袋,直接在代码里搜它对应的 API 调用:

# 例:确认到底有没有真的用到剪贴板 / 定位 / 蓝牙相关 API
Select-String -Path "web_engine\src\main\ets\**\*.ets" -Pattern "pasteboard|geoLocationManager|bluetooth" -Recurse

本工程从模板默认的 26 项砍到 14 项,删掉的 12 项分别是:

被删权限类型删除理由
CAMERAuser_grant无拍照功能
MICROPHONEuser_grant无录音功能
LOCATION / APPROXIMATELY_LOCATION / LOCATION_IN_BACKGROUNDuser_grant纯本地工具,不涉及位置
ACCESS_BLUETOOTHuser_grant无蓝牙交互
CUSTOM_SCREEN_CAPTUREuser_grant无截屏分享功能
READ_WRITE_DOWNLOAD / DOCUMENTS / DESKTOP_DIRECTORYACL文件读写走系统选择器,不需目录级权限
FILE_ACCESS_PERSISTACL走应用沙箱路径即可
READ_PASTEBOARDACL见本节下方特例

保留下来的 14 项是引擎确实依赖的系统能力,下面是 web_engine/src/main/module.json5 精简后的真实 requestPermissions:

// web_engine/src/main/module.json5 —— requestPermissions(真实内容,完整 14 项)
"requestPermissions": [
  { "name": "ohos.permission.INTERNET" },
  { "name": "ohos.permission.GET_NETWORK_INFO" },
  { "name": "ohos.permission.ACCESS_CERT_MANAGER" },
  { "name": "ohos.permission.RUNNING_LOCK" },
  { "name": "ohos.permission.PRINT" },
  { "name": "ohos.permission.PREPARE_APP_TERMINATE" },
  { "name": "ohos.permission.ACCESS_BIOMETRIC" },
  { "name": "ohos.permission.PRIVACY_WINDOW" },
  { "name": "ohos.permission.WINDOW_TOPMOST" },
  { "name": "ohos.permission.GYROSCOPE" },
  { "name": "ohos.permission.ACCELEROMETER" },
  { "name": "ohos.permission.SET_ABILITY_INSTANCE_INFO" },
  { "name": "ohos.permission.GET_FILE_ICON" },
  { "name": "ohos.permission.LOCK_WINDOW_CURSOR" }
]

注意同文件还有一个 definePermissions 段,里面也写了 LOCK_WINDOW_CURSOR,所以用正则 ohos.permission. 全扫时命中会是 15 条而不是 14 条。这是正常基线,不要据此判定"裁剪没做完",我一度在这里多查了半小时。统计前还要记得先剔除以 // 开头的注释行,否则模板里注释掉的权限会被误计入。

砍完不代表安全,留下来的每一项也得回到同一把尺子下量。 我后来拿同样的方法反向复核了一遍保留项,用一个关键词列表把 web_engine 的 ets 代码扫一次:

$root = "web_engine\src\main\ets"
foreach ($k in @('userAuth','biometric','print','TopMost','privacy','fileIcon','cursor','sensor','gyroscope','accelerometer')) {
  $n = (Get-ChildItem -Path $root -Recurse -Include *.ets -File | Select-String -Pattern $k -SimpleMatch | Measure-Object).Count
  Write-Output "$k => $n"
}

本工程实测命中的数量级是:userAuth 38、print 59、cursor 50、fileIcon 10、privacy 8、TopMost 3、biometric 4——这些权限的保留在代码里能找到对应调用,站得住。但 sensor、gyroscope、accelerometer 全部为 0 命中,也就是 GYROSCOPE 和 ACCELEROMETER 这两项在这个 Electron 壳工程的 ets 层里根本没有任何调用痕迹,按本节开头那句判据它们本来就该被划入删除之列。

这件事的正确认识方式是:裁剪是一次迭代而不是一次操作。 模板引擎的适配层在不断演进,你手上的壳快照可能比权限清单落后几个版本;手机、平板上的陀螺仪交互(如窗口旋转联动)在桌面端形态里本来就用不上。这两项在本次提审时仍在清单里且未因此被驳,但下一版我会把它们一并删掉再真机复测一次窗口旋转与拖拽行为。所以别把"审核没驳"当成"裁剪到位"的证据,它只意味着这一项当前不在人工审核的命中范围内。

READ_PASTEBOARD 是个"删不得也留不得"的特例,值得单独提醒。 直觉上它属于"用不到的敏感权限",删掉就完事了,但实测结论更严苛:

  • 保留声明会装不上。即使你给它补上 reason 与 usedScene 让 hvigor 构建通过,安装阶段照样报 error code:9568289,提示 grant request permissions failed。
  • 只删声明也会出问题。适配层的权限映射表里仍然有条目,一旦触发粘贴,就会弹出"去系统设置"的引导,而那个开关在鸿蒙 PC 上根本不存在——AGC 会直接按"功能异常"驳回。

正确的口径是把 module.json5 里的声明删干净,同时在 PermissionManagerAdapter.ets 的 requestPermissions 与 checkPermissions 方法体内对 pasteboard 提前返回成功值。这里有个必须掌握的判据:映射表里有条目、文件里能搜到 openPasteboardPermissionConfirm 这类文案构造代码,都不代表旁路已生效——该适配层有三段相互独立的实现,只有授权方法体里存在提前 return 的分支才算就绪。ApE 本身没有粘贴板交互需求,所以本工程只做了声明裁剪;但如果你做的 Electron 壳应用有 Ctrl+V,这一条一定要按上面的口径补全并真机复测。

原则就是"最小必要"——宁可少声明,审核也不会因为你没用到还声明而驳回。反过来,确实需要某个 user_grant 权限时,记得同步在隐私政策里写清"收集什么、用来干什么",否则照样驳。

四、改包名的两个连带项:签名材料 + IDE 运行配置

如果你和我一样是从示例壳改包名过来的,这一节务必看完。我在这上面连吃两次教训,而且第二个教训和签名完全无关。

改完 bundleName 后第一次构建,直接报:

> hvigor ERROR: The bundleName in app.json does not match the
  the bundleName in the generated SigningConfigs

原因是 .cer / .p7b / .p12 三件套是一整套绑死旧包名 com.huawei.ohos_electron 的,和新包名 com.wei.apeeditor 对不上。先看清楚 build-profile.json5 里签名的真实结构——它不是把字段平铺,而是包在一个 material 里(下面的口令与绝对路径已脱敏,字段名和结构保持真实):

// build-profile.json5 —— signingConfigs(真实结构,敏感值已脱敏)
"signingConfigs": [
  {
    "name": "default",
    "type": "HarmonyOS",
    "material": {
      "certpath": "C:\\Users\\<you>\\.ohos\\config\\default_ohos_hap_xxx.cer",
      "keyAlias": "debugKey",
      "keyPassword": "<加密后的密钥口令>",
      "profile": "C:\\Users\\<you>\\.ohos\\config\\default_ohos_hap_xxx.p7b",
      "signAlg": "SHA256withECDSA",
      "storeFile": "C:\\Users\\<you>\\.ohos\\config\\default_ohos_hap_xxx.p12",
      "storePassword": "<加密后的存储口令>"
    }
  }
]

关键认知:编译能全过、只在最后 SignHap 阶段挂,说明代码没问题,是签名材料和 bundleName 没配套。 正确的两条路:

  • 路径 A(正式上架必走):去 AGC 后台为新包名重新申请发布证书,下载配套三件套,替换 material 里的 certpath、profile、storeFile,keyAlias 换成新 p12 里真实存在的别名。
  • 路径 B(先本地跑通):临时把 bundleName 改回模板值 com.huawei.ohos_electron 以保持与现有调试证书一致,先把包装上验证功能,正式提审前再切回路径 A。我当时就是走路径 B 抢时间——注意这条路径只动 app.json5 一个文件,应用名、权限、图标等其他合规项保持不变。

keyAlias 别靠记忆核对,直接用 JDK 自带的 keytool 列出 p12 里真实存在的别名:

# 列出 p12 证书里实际存在的 keyAlias
keytool -list -v -keystore your_release.p12 -storetype PKCS12 -storepass yourStorePwd | Select-String "Alias name"

输出的 Alias name 才是 keyAlias 该填的值。另外补充一条复用现成签名材料的坑:material 里的 storePassword / keyPassword 是 DevEco 按工程维度加密的密文,同一张 .p12 在不同工程里有互不相同的密文。把现成的签名段整块复制过来、哪怕四个路径全改对,也会在 :SignHap 报 ERROR 11014003 并提示 keystore password was incorrect。正确做法是每个新工程都在自己的 File > Project Structure > Signing Configs 界面重新录入一次口令,由 IDE 写入本工程专属密文。排查该错误时优先怀疑密文来源,而不是 p12 损坏或 JDK 版本——日志会把三种可能并列打出来,很容易带偏。

第二个连带项,也是我这次新踩的:改了 app.json5 但 IDE 运行配置不会自动同步。 走路径 B 回退包名后,hap 构建与安装都成功了,但启动报:

Error Code:10104001
The specified ability does not exist / Ability is not installed

根因是 DevEco 的 Run Configuration 缓存了 Bundle Name,不随 app.json5 更新。处理方式是 Edit Configurations 手工把 Bundle Name 改成与 app.json5 一致的值。结论:改包名的连带项至少有两处——签名配置 + DevEco 运行配置的 Bundle Name。 只对齐一处,就会得到"构建成功、安装成功、启动失败"这种最迷惑的现象。

五、模板残留清理与内容合规

壳模板为了演示会带一些调试性质的东西,上架前必须清干净,本工程共五项。

1)移除应用快捷方式 metadata。 electron/src/main/module.json5 里 EntryAbility 原本带了一段 ohos.ability.shortcuts,指向模板演示用的 shortcuts_config.json。非应用自身功能的快捷方式会被驳回,删掉 metadata 引用并删除对应配置文件即可。处理后的 EntryAbility 已不再有 shortcuts,只保留 AppScope 级的 client_id 占位:

// electron/src/main/module.json5 —— EntryAbility(清理后的真实结构)
{
  "name": "EntryAbility",
  "srcEntry": "./ets/entryability/EntryAbility.ets",
  "label": "$string:EntryAbility_label",
  "icon": "$media:app_icon",
  "startWindowIcon": "$media:startIcon",
  "launchType": "specified",
  "exported": true,
  "skills": [
    {
      "entities": ["entity.system.home", "entity.system.browsable"],
      "actions": ["action.system.home", "ohos.want.action.viewData"],
      "uris": []
    }
  ]
}

顺便强调一句:skills 里的 entity.system.home + action.system.home 是桌面启动器路由到 Ability 的关键声明,不能删。**如果把这段留成空数组,DevEco 里点运行照样能起(不走桌面路由),但用户从桌面点图标会跳到"应用与原服务"页面,看着像应用坏了。**清理 shortcuts 时务必只删 metadata,别顺手把 skills 也带走。

2)隐藏默认菜单。 Electron 默认顶部那排 File / Edit / View / Window / Help 点几下就露馅。本工程 main.js 的真实写法是在应用就绪时先把菜单置空再建窗口:

// main.js —— 去掉顶部默认菜单栏再建窗口
app.whenReady().then(function() {
  Menu.setApplicationMenu(null);  // 去掉 File/Edit/View/Window/Help 默认菜单栏
  createWindow();
});
app.on('window-all-closed', function() { app.quit(); });

3)应用内"关于"补合规声明。 开源工具务必在"关于"里写清开源协议与免责声明,这是审核关注的内容合规点。ApE 的关于浮层不是常驻 DOM,而是右下角状态栏一个 status-link 触发、首次点击时动态构建:

<!-- index.html —— 右下角状态栏入口 -->
<span class="status-link" id="btnAbout">关于</span>
// renderer.js —— 关于浮层:右下角状态栏触发,首次点击时动态构建
function showAboutDialog() {
  var overlay = document.getElementById('aboutOverlay');
  if (!overlay) {
    overlay = document.createElement('div');
    overlay.id = 'aboutOverlay';
    overlay.className = 'dialog-overlay';
    overlay.innerHTML =
      '<div class="dialog-box" style="min-width:420px;max-width:520px">' +
        '<div class="dialog-title">关于 ApE 质粒编辑器 <span class="about-badge">完全开源</span></div>' +
        '<div class="about-content">' +
          '<h3>开源声明</h3>' +
          '<p>ApE(A Plasmid Editor)质粒编辑器鸿蒙 PC 版为从零自研的 Web 实现,' +
          '基于 Electron 壳架构,前端采用纯 SVG 渲染,无任何外部网络依赖,完全离线运行。' +
          '本项目尊重原版 ApE(Rod Page, Bowtell Institute)的功能设计,但代码全部独立编写,' +
          '不涉及原版源码的复制或反编译。</p>' +
          '<h3>免责声明</h3>' +
          '<p>本软件仅供教学与科研参考使用,不提供任何形式的明示或暗示担保。' +
          '使用者须自行验证质粒图谱、酶切位点、序列比对等分析结果的准确性,' +
          '因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。</p>' +
          '<h3>完全开源</h3>' +
          '<p>本项目代码完全开源,遵循 MIT 开源许可证。' +
          '欢迎社区贡献代码、报告问题或提出功能建议。</p>' +
        '</div>' +
        '<div class="dialog-buttons">' +
          '<button class="tool-btn" id="btnCloseAbout" style="padding:4px 16px">确定</button>' +
        '</div>' +
      '</div>';
    document.body.appendChild(overlay);
  }
  overlay.style.display = 'flex';
}

写免责声明时有个必须按真实行为改写的细节:声明里的每一句都要能在代码里对上。ApE 完全离线,写"无任何外部网络依赖,完全离线运行"就是准确的;但若应用会调云端 API,这句话必须改成"除用户主动配置的 API 调用外,不包含其他网络传输功能",否则反而成了审核风险点。

再提醒一个几乎人人会漏的合规点:开源协议前后不一致。 本工程就真实存在这处矛盾——关于浮层第三段写的是"遵循 MIT 开源许可证",而 package.json 的 license 字段填的是 “Apache-2.0”:

{
  "name": "ape",
  "version": "1.0.0",
  "description": "ApE 质粒编辑器 - 鸿蒙 PC Electron 壳应用",
  "main": "main.js",
  "author": "wei",
  "license": "Apache-2.0"
}

MIT 和 Apache-2.0 是两套条款不同的许可(后者额外涉及专利授权与 NOTICE 保留义务),同时出现等于对外给了两个互相矛盾的承诺。AGC 人工审核不一定逐字比到这一层,但你一旦在 AtomGit 仓库放了 LICENSE 文件,就变成"应用内文案、package.json、仓库 LICENSE"三方要对齐。上架前统一到同一个协议,改浮层文案还是改 package.json 由你选,关键是三方只能有一个答案,且第九节清单里专门为此留了一条。

4)图标不能用默认。 AppScope/resources/base/media/ 下的 app_icon.png、product_logo_32.png、startIcon.png 三张换成自己的图标。electron 模块下通常不存在独立的 media 目录,module.json5 里的 media:appicon、media:app_icon、media:appi​con、media:startIcon 和 app.json5 的 icon 全部解析到 AppScope 这一组文件,只需换 AppScope 三件套即可全链生效。换完用哈希比对确认同源:

Get-FileHash "AppScope\resources\base\media\app_icon.png","AppScope\resources\base\media\product_logo_32.png","AppScope\resources\base\media\startIcon.png" -Algorithm SHA256

但这里有个坑中坑,本工程出包时实测踩过:源文件三张哈希同源,不等于产物里的图标是对的。构建管线只对 app_icon.png 做 1024×1024 到 512×512 的缩放并把缩放结果缓存在 build 目录的 .caches 下,即使完全退出 DevEco、Clean Project、删掉三个 build 目录重建,产物里的 app_icon.png 仍可能是旧图,而另外两张正常更新。出包前务必嵌套解包 signed.app 对三张图标同时比对哈希与宽高:三张全错是复制型缓存,删 build 重建可修;只有 app_icon 错是缩放缓存残留,重复删目录无效,需要改文件名或把源图预缩放到目标尺寸绕开。手动改写 .app 内的图标是禁止操作,.hap 已签名,改动即签名失效被拒。

5)隐私弹窗名称联动。 壳模板自带一个登录相关页面 electron/src/main/ets/pages/QuickLoginButtonComponent.ets,里面的隐私弹窗文案通过 $r(‘app.string.app_name’) 引用应用名,第二节改了 string.json 后它会自动同步,文案本身不用单独改。但这里有个验证手段要理顺:ApE 并没有真实的登录流程,这个页面运行时基本走不到,所以这一项没法靠"真机点开看一眼"来确认,只能做静态核查——直接读这个 ets 文件确认它用的是资源引用而不是写死的名称,再回 string.json 确认引用值正确。之所以仍要多查这一遍,是因为 AGC 做的是静态资源与代码分析,即使主页没有路由入口,静态页面里残留的旧名称、占位文案照样会被挑出来。清理范围要按"全包穷举"来算,不能按"我能点到哪些页面"来算。若你的应用确实有登录入口,这一项再恢复成"真机点开核对弹窗文案"的做法。

六、真正的硬骨头:四项"功能异常"驳回的复现与修复

配置改完、包打出来、后台填完,第一轮驳回的意见是权限与名称——按上面几节走完就解决了。第二轮驳回的意见完全变了口径,是四条功能异常:

  1. 第二次点击保存无反应。
  2. 已切换到线性视图,右侧信息面板的拓扑仍显示"环状"。
  3. 应用操作一段时间后会偶现闪退。
  4. 酶切分析点击后展示内容不变。

这四条都不是壳工程配置问题,而是鸿蒙壳运行环境与传统 Electron 行为差异造成的。下面逐个说现场、根因和最终代码。

6.1 第二次点击保存毫无反应

现象:新建质粒后第一次 Ctrl+S 会弹保存对话框,选完路径写入成功;第二次按保存(此时已有 currentFile,应该直接覆写)却毫无反应——没有报错、没有状态变化,就像按键失效。

根因:第一反应是逻辑写错了,但分支判断完全正常——saveFile 里判断 state.currentFile 有值就走直接写入。真正的元凶是壳环境下同一路径上重复调用 ipcRenderer.invoke 存在既不 resolve 也不 reject 的静默挂起:首次"对话框 + 写入"两步都过 IPC 没问题,第二次纯写入的 invoke 挂住后,.then 永不执行、.catch 也捕不到,界面自然毫无反馈。

第一轮修复无效:我先加了四态状态栏反馈(保存中 / 已保存 / 失败 / 异常)并给 performSave 补了 .catch()。理论上线了,真机复测仍然没反应——因为 promise 根本不进入任何回调,.catch 无从触发。这一步的价值只是让我确认"错误没发生,是根本没回来"。

最终修复:弃用 IPC 写入,改为渲染进程直接 require(‘fs’) 同步写。本工程 webPreferences 里 nodeIntegration 为 true、contextIsolation 为 false,而同一份 renderer.js 里的 exportSvg 早就在用这个模式且一直稳定,直接复用这个已被验证的路径:

// renderer.js —— 保存实现:渲染进程直接同步写文件(与 exportSvg 同模式)
// 不再走 ipcRenderer.invoke('file:write'):鸿蒙壳环境下第二次 invoke
// 存在挂起/静默失败的情况(首次对话框+写入正常,二次直写无反应),
// nodeIntegration 已开启,直接 fs 同步写入 + 同步 try/catch,
// 成功弹框提示 + 左下角文件状态同步,无 IPC 依赖。
function performSave(filePath) {
  try {
    var fs = require('fs');
    var content = generateGenBank(state.plasmid);
    fs.writeFileSync(filePath, content, 'utf-8');
    state.isModified = false;
    var fileName = String(filePath).split(/[\\/]/).pop();
    var sizeText = (content.length / 1024).toFixed(1) + ' KB';
    // 左下角文件状态同步:由"未保存"变为实际文件名
    if (statusFile) { statusFile.textContent = fileName; }
    statusInfo.textContent = '已保存: ' + fileName + ' (' + sizeText + ')';
    showSaveToast(fileName, sizeText);
  } catch (e) {
    statusInfo.textContent = '保存失败: ' + (e && e.message ? e.message : '写入异常');
    showSaveToast('保存失败', (e && e.message ? e.message : '写入异常'), true);
  }
}

同步收益:写入改成同步后,try/catch 能立即捕获并给出可见反馈,零 IPC 依赖。文件选择对话框仍然走 IPC(showSaveDialog 只在"第一次保存"时调用一次,不触发重复 invoke 问题),且要注意 main.js 的 showSaveDialog 返回字段是单数 filePath,前端读 result.filePath,两者必须对齐:

// renderer.js —— 只有"路径未知"时才走一次对话框 IPC
function saveFile() {
  if (!state.plasmid) { statusInfo.textContent = '无质粒数据'; return; }
  if (state.currentFile) {
    performSave(state.currentFile);
  } else {
    ipcRenderer.invoke('file:saveDialog').then(function(result) {
      if (result && !result.canceled && result.filePath) {
        state.currentFile = result.filePath;
        performSave(result.filePath);
      }
    });
  }
}

同理,openFile 里的文件内容读取也改成渲染进程 fs.readFileSync 直读,把"打开"路径上的重复 invoke 一并消掉。

通用教训:壳环境里出现"点了没反应、控制台也没报错"时,优先怀疑 IPC 静默挂起,而不是业务逻辑;修复方向是复用同工程内已被真机验证过的能力路径,不要凭空发明新写法。

6.2 切到线性视图,信息面板仍写"环状"

现象:点"视图"按钮图形确实变成线性排布了,但右侧"质粒信息"里的"拓扑"一行始终显示"环状",怎么切都不动。

根因:这是同一语义存在两份状态。state.plasmid.viewMode 是字符串 ‘circular’ / ‘linear’,state.plasmid.circular 是另一份布尔副本。图形渲染读 viewMode,而信息面板读的是早期实现留下的 circular——切换时只改 viewMode,circular 永远停在初始 true,所以面板纹丝不动。这类"展示内容不变"的驳回,共性成因几乎都是面板读的属性与渲染用的属性不是同一个。

修复:在唯一的切换入口 toggleViewMode 里显式同步副本,并让面板读权威来源。渲染函数每次重绘都会重写这一行,从此不会出现视图与文字背离:

// renderer.js —— 视图切换:同步 circular 副本,确保拓扑显示与视图一致
function toggleViewMode() {
  if (!state.plasmid) { return; }
  state.plasmid.viewMode = state.plasmid.viewMode === 'circular' ? 'linear' : 'circular';
  state.plasmid.circular = (state.plasmid.viewMode === 'circular');
  renderPlasmid();
  statusInfo.textContent = '视图: ' + (state.plasmid.viewMode === 'circular' ? '环形' : '线性');
}

// renderer.js —— renderPlasmid 内:面板直接读 viewMode,不碰 circular
infoTopology.textContent = state.plasmid.viewMode === 'linear' ? '线性' : '环状';

判据:排查此类问题先回答"这个字段有几处赋值、谁在读"。我确认过 state.currentFile 全工程只有三处赋值(newPlasmid 置 null、openFile 赋打开路径、saveFile 对话框回调赋保存路径),无意外清空点,所以保存链路是干净的;而 circular 恰恰因为存在两份独立来源才出的问题。同一语义应只保留一个权威来源,若为兼容必须留副本,就在切换处显式同步。

6.3 操作一段时间后偶现闪退

现象:刚开始一切正常,反复点特征弧段、拖几次缩放,过一阵应用整体退出,没有任何错误弹窗。

根因:这是四条里最隐蔽的一条。渲染函数 renderPlasmid 每次都会调用 bindFeatureClicks 重新绑定交互,而 plasmidView 这个容器元素从不重建。原来的写法是:

// 修复前的错误写法(示意,已不在工程中)
plasmidView.addEventListener('click', handler);  // 每次渲染都新增一个监听器

于是监听器数量随渲染次数线性堆积,而且 handler 内部还会再调 renderPlasmid —— 一次点击触发 N 个回调,每个回调又渲染一次,N 变成 2N。快速点几下就进入指数级放大,内存被打满,进程直接崩溃。这正好解释了"操作一段时间后偶现"的现象:不是随机,是累积。

修复:常驻元素改用 onclick 属性赋值(每次覆盖,永不堆积),并对缩放这类高频操作加防抖,避免连续拖动时反复重建 SVG:

// renderer.js —— 空白点击取消选中:用 onclick 属性赋值而非 addEventListener,
// 每次渲染覆盖旧回调,避免监听器随渲染次数堆积(堆积是偶现闪退根因)
function bindFeatureClicks() {
  var arcs = plasmidView.querySelectorAll('.feature-arc');
  arcs.forEach(function(arc) {
    arc.addEventListener('click', function(e) {
      e.stopPropagation();
      var idx = parseInt(arc.getAttribute('data-feature-index'), 10);
      state.plasmid.selectedFeatureIndex = (state.plasmid.selectedFeatureIndex === idx) ? -1 : idx;
      renderPlasmid();
    });
    arc.addEventListener('dblclick', function(e) {
      e.stopPropagation();
      var idx = parseInt(arc.getAttribute('data-feature-index'), 10);
      showFeatureEditDialog(idx);
    });
  });
  plasmidView.onclick = function() {
    if (state.plasmid && state.plasmid.selectedFeatureIndex >= 0) {
      state.plasmid.selectedFeatureIndex = -1;
      renderPlasmid();
    }
  };
}

// renderer.js —— 带防抖的渲染(用于缩放等高频操作)
var renderTimer = null;
function renderPlasmidDebounced() {
  if (renderTimer) { clearTimeout(renderTimer); }
  renderTimer = setTimeout(renderPlasmid, 60);
}

注意两者的区别:特征弧段的监听器加在每次重建的 SVG 子元素上,随元素一起丢弃,不会堆积;只有绑在常驻容器上的那一个才是雷。这个边界判据值得记住——不是所有 addEventListener 都有问题,只有"绑到不会被销毁的常驻节点上的重复绑定"才有问题。

6.4 酶切分析点击后"展示内容不变"

现象:选中某个特征后点"酶切",面板出来的位点列表和全选状态一模一样,审核员据此判定功能未生效。

根因:不是渲染没刷新,而是逻辑本来就没接入选中上下文——按钮回调永远对整个序列做全量扫描,selectedFeatureIndex 这个状态被完整维护着,却没被任何分析功能读取。用户感知就是"我明明选中了它,结果和没选一样"。

修复:让分析结果按选中特征的区间过滤,未选中时保持全序列分析:

// renderer.js —— 酶切分析:有选中特征时仅展示该特征区间内的位点
function runEnzymeAnalysis() {
  if (!state.plasmid || !state.plasmid.sequence) {
    statusInfo.textContent = '无序列数据';
    return;
  }
  var sites = findRestrictionSites(state.plasmid.sequence);
  var selIdx = state.plasmid.selectedFeatureIndex;
  if (selIdx >= 0 && selIdx < state.plasmid.features.length) {
    var selFeature = state.plasmid.features[selIdx];
    var fStart = Math.min(selFeature.start, selFeature.end);
    var fEnd = Math.max(selFeature.start, selFeature.end);
    sites = sites.filter(function(s) {
      return s.position >= fStart && s.position <= fEnd;
    });
    statusInfo.textContent = '酶切分析 (' + selFeature.label + '): 找到 ' + sites.length + ' 个位点';
  } else {
    statusInfo.textContent = '酶切分析: 找到 ' + sites.length + ' 个位点';
  }
  state.enzymeResults = sites;
  enzymePanel.style.display = 'flex';
  renderEnzymeResults(sites);
}

同时把状态栏提示也带上上下文(酶切分析 (CDS_XXX): 找到 N 个位点),让"结果确实按选中项变化了"这件事在界面上可被一眼验证——给审核员看得见的反馈,本身就是防驳回手段。

七、驳回项原样重复下发时,先静态核查再决定动不动代码

第三轮 AGC 反馈过来,我点开一看,四条意见和第二轮一字不差。

这时候最容易犯的错是"照着原意见再改一遍"——但代码已经改过了,重写等于引入回归风险。正确做法是先做一次纯只读的静态核查,逐项确认修复仍在位:

  1. performSave / openFile 是否仍是 fs 同步直写,state.currentFile 有无意外清空点。
  2. toggleViewMode 里 circular 同步是否还在,renderPlasmid 读的是不是 viewMode。
  3. plasmidView 是否为 onclick 属性赋值,renderPlasmidDebounced 的 60ms 防抖是否生效。
  4. runEnzymeAnalysis 是否仍按 selectedFeatureIndex 过滤。
  5. JS 里引用的静态 DOM id(infoTopology、statusFile、enzymePanel、btnToggleView)在 index.html 中是否全部存在。
  6. main.js 对话框返回字段名与前端读取是否一致(filePath 单数)。

全部通过后,再在真机上按审核步骤复现一次。结论是代码侧无缺陷,属于误报,一行代码都没改。

第 5 条得补一句防坑提醒:不要把动态创建的 id 也算进去。本工程 renderer.js 里 getElementById 命中的 aboutOverlay、editFeatureOverlay、warSaveToast、contextMenu 以及特征编辑弹窗内的 editLabel / editStart / editEnd / editType / editNote / editComplement,在 index.html 里全是零匹配——但它们本来就是由 innerHTML 构造后 appendChild 上去的,首次打开浮层后 getElementById 就能命中。比对前先分清这个元素是静态写在 index.html 里还是运行时动态构建,否则一份十几个"缺失 id"的伪清单能把你带偏到完全错误的方向。

这里有个更常见的相关情形要一并提醒:构建产物被 IDE 旧缓冲区还原。编辑工具返回 success 不代表改动最终落盘——DevEco 打开着的旧标签页会在稍后把文件写回模板版。本工程实测命中过两次:zh_CN 的 string.json 被还原成 “Electron”,web_engine 的 module.json5 被整体还原成模板默认版,26 项权限全部回来,此前刚裁到 14 项的成果一夜清零。所以名称与权限计数的回读校验必须放在全部改动的最后一道再跑一次,并养成"构建前先关闭 DevEco 里本项目旧标签页(不保存)再重开"的习惯。

顺带一条判据:末轮回读时若 Grep 命中 build/default/intermediates 下的旧名称,那属于构建缓存产物,重新构建会自动覆盖,不据此判定清理未完成,更不要手动去改产物。

八、AGC 后台元数据与提审材料

壳工程改完、包打出来、功能复测过,还剩后台那一堆元数据。几个必填项的填写建议:

  • 应用名称:与包内 app_name 严格一致,本例为 “ApE 质粒编辑器”,注意空格与大小写。
  • 应用介绍:写成一段连续成文的文本(约 200 字),不要分点堆参数,内容依次覆盖"开源免费 → 核心功能 → 本地运行与隐私安全 → 适用人群"。本例围绕"鸿蒙 PC 桌面端的离线质粒图谱工具,支持 GenBank 读写、环形与线性双视图、限制酶切分析、IUPAC 简并码序列搜索与特征编辑"展开。
  • 一句话简介(小编推荐):17 字以内,且按字符计数(中文、字母、数字、标点全部占位)。这里有个已踩过驳回的细节:写到恰好 17 字会让结尾词被截成半字并以"描述不完善"驳回,所以务必留出余量,并保证最后一个词(工具 / 编辑器 / 平台)语义完整。本例采用**“鸿蒙离线质粒编辑器”**——只占 9 个字符,余量充足且结尾词完整;如果想把功能也带进一句话,可写成"离线质粒图谱编辑与酶切分析工具",刚好 15 字仍留 2 字余量。不要直接拿上一句去掉"离线"两字去凑,凑到 16、17 字就重新进入高风险区。
  • 关键词标签 / 应用分类:与功能描述一致,工具类 / 教育类均可。
  • 隐私政策链接:必须可正常访问,内容覆盖你声明的每一个 user_grant 权限的用途——这又回到第三节的"最小必要",权限删干净,隐私政策才好写。
  • 应用截图:上传鸿蒙 PC 桌面端真机的完整窗口截图(带标题栏与任务栏),审核据此确认适配形态与宣传一致,不要交 Web 页面截图或裁剪过的局部图。
  • 版权 / 软著:开源项目需在"关于"里写清参考来源、独立重写、完全开源,并与后台声明保持一致。

这些内容本身不难,难的是和包内实际表现保持一致——后台写一套、应用里显示另一套,是人工审核逐条比对时最常见的驳回原因。另外发布时记得不要把应用设成关注可见或 VIP 专享,后台元数据要对所有审核人员可见。

九、上架前自查清单(对着勾)

把上面所有点收敛成一份可复用清单,每次出包前从头过一遍:

  • bundleName 全仓统一,app.json5 与签名材料配套,构建能过 SignHap
  • 改包名后 DevEco 运行配置的 Bundle Name 已手工同步(否则装得上、起不来)
  • vendor 为 AGC 真实开发者名,不是 example,也不是产品名
  • app_name 与 base / en_US / zh_CN 三套 EntryAbility_label 全部一致(含空格)
  • web_engine module.json5 权限精简到 14 项,无未说明用途的 user_grant / ACL
  • 保留项已反向复核:每个权限在 ets 层都能找到对应 API 调用(本工程 sensor / gyroscope / accelerometer 为 0 命中,属下一版待删项)
  • 权限计数已剔除注释行,definePermissions 重复项已按基线理解(grep 15 = 14 + 1)
  • 若应用有粘贴需求:READ_PASTEBOARD 声明已删且适配层授权方法体内旁路已生效
  • main.js 已 Menu.setApplicationMenu(null)
  • ohos.ability.shortcuts metadata 与 shortcuts_config.json 已移除,但 skills 保留 entity.system.home
  • 三张图标已替换,AppScope 内哈希同源;已嵌套解包 signed.app 复核产物图标(含宽高)
  • "关于"含开源声明 + 免责声明 + 开源协议,措辞与应用真实网络行为一致;隐私弹窗应用名正确
  • 开源协议已三方对齐:关于浮层文案、package.json 的 license、AtomGit 仓库 LICENSE 是同一个答案
  • 保存 / 打开走渲染进程 fs 直写,零重复 IPC 依赖,失败有可见反馈
  • 展示面板读的字段与渲染用的是同一个(同一语义单一权威来源)
  • 常驻节点交互用 onclick 属性赋值,高频渲染有防抖
  • 分析类功能的选中上下文确实生效,且状态栏有可见反馈
  • 已在鸿蒙 PC 真机按审核步骤逐项走查核心交互并截图
  • 未被路由直达的静态页面(如壳模板带的登录/隐私组件)也做过名称与文案穷举
  • AGC 后台:应用名 / 简介 / 隐私政策 / 截图与包内表现完全一致
  • 最后一轮回读确认无 IDE 旧缓冲区还原(旧标签页已关闭)

十、总结

ApE 质粒编辑器这次上架走的是"配置驳回 + 功能异常驳回"双重闭环,比单纯改配置更折磨人,但也因此把 Electron 壳方案上鸿蒙桌面端的完整坑位摸了一遍。最值得记住的是四点:

  1. 功能跑通 ≠ 能过审,而且卡点分两类。配置类(名称、权限、包名、签名、图标、开源协议、模板残留)是静态可核查的,改完就能过构建门与合规门;功能类驳回必须靠真机走查,因为壳运行环境和传统 Electron 的行为差异(IPC 静默挂起、常驻节点监听器堆积)在本地开发时完全暴露不出来。上架前务必按审核员的步骤在鸿蒙 PC 上逐项点一遍。
  2. 改包名的连带项不止签名。bundleName 与 p12 / cer / p7b 是绑死的一整套,改完必在 SignHap 挂;但这次还多踩了一个——DevEco 的 Run Configuration 缓存 Bundle Name 不随 app.json5 更新,会出现"构建成功、安装成功、启动报 10104001"这种极具迷惑性的现象。两处要同轮改掉。
  3. "点了没反应"和"内容不变"各有固定成因套路。重复 invoke 静默挂起 → 复用同工程内已验证的渲染进程 fs 直写;展示与渲染读的不是同一字段 → 同一语义只留一个权威来源或显式同步;偶现闪退 → 常驻节点上重复 addEventListener 导致监听器指数堆积 → 改 onclick 覆盖 + 防抖。这三条也是 Electron 壳方案下功能类驳回的通用修复套路。
  4. 驳回原样重复下发时先做静态核查。逐项确认修复代码仍在位、静态 DOM id 与 JS 引用一一对应(动态创建的浮层 id 不在此列)、IPC 返回字段名与前端读取一致,再决定动不动代码;本工程还实测过 IDE 旧缓冲区把配置整体还原,所以名称与权限的最后一轮回读是必备动作,不是可选动作。

按这份清单走完,AGC 的常见驳回基本都能提前规避。如果你也在用 Electron 壳方案往鸿蒙 PC 桌面端上架,欢迎加入文首的开源鸿蒙 PC 社区,一起交流更多踩坑经验。

Logo

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

更多推荐