鸿蒙 PC 桌面端应用上架实战:主进程只有 81 行的 PLC 编辑器,字节码里却找不到一行梯形图代码

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

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

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

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


这个应用叫逻控 PLC 编辑器。它做的事不小:梯形图编程、七类逻辑元件、简易仿真执行、项目 JSON 的打开与保存,全部在本地完成。

但它的壳薄得反常:主进程 81 行,只有两个 IPC 通道。

更反常的是包里装的东西。把提审的 .app 拆开,取出里面的字节码文件 modules.abc(912,608 字节),在里面搜业务代码的特征串:

  • ladderCanvas:0 次
  • file:open:0 次
  • openplc:0 次

一行梯形图代码都找不到。

同一份 912,608 字节里,另一个串出现了 107 次:QuickLoginButtonComponent。还有一个 9 次:LoginWithHuaweiIDButton。

这三组数字就是本篇要讲的事:壳可以很薄,但模板留下的东西不会自己消失。

先把边界划清楚。梯形图数据模型怎么设计、Canvas 怎么画触点与线圈、变量表怎么联动、项目怎么序列化,这些引擎与界面的实现细节本篇不展开。

本篇只回答上架前必须弄明白的三个问题:

  1. 一个功能这么密的桌面应用,壳为什么能只有 81 行,逻辑到底放在哪;
  2. 名称改了 12 处之后,为什么上游名字还留了 1 处,留得对不对;
  3. 包里那 912,608 字节到底装了谁,为什么业务代码一个字都没进去。

一、先把六个文件量一遍

业务代码没有打包器、没有 node_modules、没有 bundle 产物,就平铺在 resfile 的 app 目录下,六个文件:

  index.html                 6281 B  行  142  crlf  142
  main.js                    2767 B  行   81  crlf   81
  renderer.js               18864 B  行  521  crlf  521
  plc-engine.js             17825 B  行  544  crlf  544
  styles/openplc.css         9351 B  行  437  crlf  437
  package.json                213 B  行    8  crlf    8
  合计                      55301 B  行 1733

package.json 全文只有八行:

{
  "name": "luokongplc",
  "version": "1.0.0",
  "description": "逻控 PLC 编辑器 - 鸿蒙应用(梯形图 + 结构化文本)",
  "main": "main.js",
  "author": "wei",
  "license": "Apache-2.0"
}

没有 dependencies,没有 scripts,没有 build 字段。Electron 直接 loadFile 打开 index.html,六个文件就是全部。

这套结构有一个直接好处:上架前核对包内资源,只需要比对六个文件。后面第十三节会用到这一点,包内这六个文件与磁盘逐字节相同,6 个全中。

顺便说一句命名:包的 name 是 luokongplc,是"逻控 PLC"的拼音;bundleName 是 com.wei.luokongplc,同一个词根。名字换了,包名跟着换,这条链路是通的。


二、81 行的主进程,只干两件事

main.js 全文如下,81 行,一行不多:

// 逻控 PLC 编辑器 主进程
// 职责:窗口管理 + 文件操作 IPC(打开/保存 PLC 项目 JSON)
var electron = require('electron');
var app = electron.app;
var BrowserWindow = electron.BrowserWindow;
var screen = electron.screen;
var ipcMain = electron.ipcMain;
var dialog = electron.dialog;
var Menu = electron.Menu;
var fs = require('fs');
var path = require('path');

app.disableHardwareAcceleration();

// 移除应用级默认菜单栏(File/Edit/View/Window/Help)
Menu.setApplicationMenu(null);

var mainWindow = null;

function createWindow() {
  try {
    var display = screen.getPrimaryDisplay();
    mainWindow = new BrowserWindow({
      width: Math.floor(display.workAreaSize.width * 0.95),
      height: Math.floor(display.workAreaSize.height * 0.90),
      frame: true,
      transparent: false,
      resizable: true,
      webPreferences: { nodeIntegration: true, contextIsolation: false }
    });
    mainWindow.loadFile('index.html');
    // 窗口级菜单移除(与应用级双保险)
    mainWindow.setMenu(null);
  } catch (e) {
    console.warn('[逻控] 创建窗口失败:', e.message);
  }
}

app.whenReady().then(function() { createWindow(); });
app.on('window-all-closed', function() { app.quit(); });

// ========== 文件操作 IPC ==========

// 打开 PLC 项目 JSON
ipcMain.handle('file:open', function() {
  return dialog.showOpenDialog(mainWindow, {
    title: '打开 PLC 项目',
    filters: [{ name: 'PLC 项目', extensions: ['plc'] }, { name: 'JSON 文件', extensions: ['json'] }],
    properties: ['openFile']
  }).then(function(result) {
    if (result.canceled || result.filePaths.length === 0) {
      return { success: false, message: '取消打开' };
    }
    var filePath = result.filePaths[0];
    try {
      var content = fs.readFileSync(filePath, 'utf-8');
      return { success: true, filePath: filePath, content: content };
    } catch (e) {
      return { success: false, message: '读取失败: ' + e.message };
    }
  });
});

// 保存 PLC 项目 JSON
ipcMain.handle('file:save', function(event, content) {
  return dialog.showSaveDialog(mainWindow, {
    title: '保存 PLC 项目',
    defaultPath: 'PLC项目.plc',
    filters: [{ name: 'PLC 项目', extensions: ['plc'] }, { name: 'JSON 文件', extensions: ['json'] }]
  }).then(function(result) {
    if (result.canceled || !result.filePath) {
      return { success: false, message: '取消保存' };
    }
    try {
      fs.writeFileSync(result.filePath, content, 'utf-8');
      return { success: true, filePath: result.filePath };
    } catch (e) {
      return { success: false, message: '保存失败: ' + e.message };
    }
  });
});

读下来只有四个动作:

动作落点说明
关硬件加速L13disableHardwareAcceleration,壳内渲染走软件路径
关菜单L16、L33应用级与窗口级各关一次,双保险
开窗L20-L37屏幕可用区 95% 宽、90% 高,带边框、可缩放
两个 IPCL45、L65只做对话框与读写文件,不碰业务

窗口参数值得单独看:

width       workAreaSize.width  * 0.95  后取整
height      workAreaSize.height * 0.90  后取整
frame       true
transparent false
resizable   true
nodeIntegration   true
contextIsolation  false

比例取 95% 与 90%,不是写死分辨率,也不是全屏。留边的好处是窗口贴边时系统任务栏与窗口控制按钮都不会被遮住;带边框的好处是拖动、缩放、最大化全部交给系统,壳里不用自己画标题栏。

nodeIntegration 打开、contextIsolation 关闭,是这套 Electron 壳的既定前提(渲染进程可以直接 require)。安全边界因此不在 preload,而在这两个 IPC 上:dialog 由主进程弹,路径由系统返回,业务侧拿不到任意路径的写权限。

取消分支也做了:打开取消返回"取消打开",保存取消返回"取消保存",都是 success 为 false,渲染侧据此不覆盖当前项目。这类分支最容易漏,漏了就会出现"点了取消,画布被清空"的问题。

【配图位 2:主进程 81 行的编辑器截图,左侧行号 1-81,滚动到中段,能同时看到 L16 的 Menu.setApplicationMenu(null) 与 L33 的 mainWindow.setMenu(null),以及 L45 的 file:open。用来证明"主进程真的只有 81 行",不是只截了个开头。】


三、逻辑下沉:521 行渲染器与 544 行引擎

主进程不管业务,业务全在渲染进程。分工是这样切的:

文件字节行数管什么
main.js2,76781窗口、对话框、文件读写
renderer.js18,864521事件绑定、状态管理、列表渲染、画布调度
plc-engine.js17,825544数据模型、七类元件、Canvas 绘制、仿真执行、序列化
styles/openplc.css9,351437三段式布局、深色主题、对话框

渲染器开头就把要用的 DOM 集中成一个对象,后面只认这个对象,不再零散地 getElementById:

  el = {
    canvas: document.getElementById('ladderCanvas'),
    canvasContainer: document.getElementById('canvasContainer'),
    programList: document.getElementById('programList'),

画布尺寸不写死,初始化时从容器量:

function resizeCanvas() {
  var container = el.canvasContainer;
  el.canvas.width = container.clientWidth;
  el.canvas.height = container.clientHeight;
}

这一步是 Canvas 应用最容易出错的地方:canvas 的 width、height 属性是绘图缓冲区尺寸,与 CSS 显示尺寸是两套值。只设 CSS 不设属性,画面会被拉伸模糊;只设属性不随窗口变化,缩放窗口后内容会错位。这里用容器实测值同时喂给属性,窗口一改就重新量。

初始化顺序也能看出耦合关系:

L50  bindAboutEvents();              绑定关于对话框
L53  requestAnimationFrame(renderLoop);   启动渲染循环

两行都在 DOMContentLoaded 回调里,L54 的 }); 收尾,所以顺序是固定的:先把关于按钮、关闭按钮、遮罩三处监听挂上,再排第一帧。


四、引擎 544 行:七类元件、27 个函数、零依赖

引擎文件头三行就把约束写死了:

// 逻控 PLC 编辑器 引擎
// 职责:梯形图数据模型 + Canvas 2D 渲染 + 简化执行引擎 + 序列化
// 约束:纯 Canvas 2D 渲染,无第三方库依赖

第三行是关键。整个引擎 require 出现 0 次,module.exports 出现 1 次,没有任何第三方绘图库。文件里一共 27 个函数定义,全部自己写。

元件类型只有七种:

var ELEMENT_TYPES = {
  CONTACT_NO: 'contact_no', // 常开触点 --| |--
  CONTACT_NC: 'contact_nc', // 常闭触点 --|/|--
  COIL: 'coil',             // 线圈 --( )--
  TIMER_TON: 'timer_ton',   // 接通延时定时器
  TIMER_TOF: 'timer_tof',   // 断开延时定时器
  COUNTER_CTU: 'counter_ctu', // 加计数器
  COUNTER_CTD: 'counter_ctd'  // 减计数器
};

七种类型在文件里的出现次数很能说明复杂度分布:

CONTACT_NO     4 次
CONTACT_NC     4 次
COIL           4 次
TIMER_TON      8 次
TIMER_TOF      6 次
COUNTER_CTU    8 次
COUNTER_CTD    7 次

触点与线圈各 4 次,因为它们是纯布尔运算,画一遍、算一遍就完了。定时器与计数器出现 6 到 8 次,因为它们有内部状态:计时起点、累计值、复位条件、位输出,每个环节都要单独处理。复杂度的重量不在图形,在时序。

数据模型也很克制:

function createPlcProject() {
  return {
    programs: [],
    variables: {
      inputs: {},    // %IX0.0, %IX0.1, ...
      outputs: {},   // %QX0.0, %QX0.1, ...
      markers: {},   // %MX0.0, %MX0.1, ... (内部位)
      timers: {},    // %T0, %T1, ...
      counters: {}   // %C0, %C1, ...
    },
    nextId: 1
  };
}

五类变量用 IEC 61131-3 的地址前缀区分:%IX 输入位、%QX 输出位、%MX 内部位、%T 定时器、%C 计数器。这个选择有实际意义:项目文件存成 JSON 之后,地址就是键,读回来不需要再猜类型。id 用单调递增的字符串:

function genId(project) {
  return 'plc_' + (project.nextId++);
}

不用随机数、不用时间戳。随机数有碰撞概率,时间戳在同一毫秒内批量创建会重号,单调计数是这里最省事又不重复的做法。


五、每帧一次全量重绘

渲染循环在 renderer.js 的 L474 到 L492,有效代码就三行:

// ========== 渲染循环 ==========
function renderLoop() {
  var ctx = el.canvas.getContext('2d');
  ctx.fillStyle = '#1e1e2e';
  ctx.fillRect(0, 0, el.canvas.width, el.canvas.height);

  if (state.currentProgId) {
    renderLadder(ctx, state.project, state.currentProgId, {
      width: el.canvas.width,
      height: el.canvas.height
    });
    
    // 绘制选中元素的高亮框
    if (state.selectedElemId) {
      drawSelectionHighlight(ctx, state.project, state.currentProgId, state.selectedElemId);
    }
  }

  requestAnimationFrame(renderLoop);
}

没有脏矩形、没有分层画布、没有离屏缓存。每一帧:刷底色、画全部梯级、有条件地画选中框、再排下一帧。

这种写法在游戏里会被骂,在这个场景里没问题,原因在几何常量上:

function renderLadder(ctx, project, progId, options) {
  var opt = options || {};
  var prog = findProgram(project, progId);
  if (!prog) return;

  var rungHeight = 100;
  var startY = 30;

梯级高度固定 100 像素,起始 Y 固定 30。一个梯级占的绘制工作量是常数,总工作量只与梯级数量线性相关。一个项目里放几十个梯级已经很多了,每帧画几十条水平线、几百个小图形,对桌面级 GPU 或软件光栅都是可以忽略的量。

梯级本身的画法是"先母线、后导线":

function drawRung(ctx, rung, y, opt) {
  var startX = 40;
  var endX = (opt.width || 800) - 40;
  var midY = y + 50;

  // 左侧母线
  ctx.strokeStyle = opt.railColor || '#89b4fa';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(startX, y);
  ctx.lineTo(startX, y + 80);
  ctx.stroke();

  // 右侧母线
  ctx.beginPath();
  ctx.moveTo(endX, y);
  ctx.lineTo(endX, y + 80);
  ctx.stroke();

  // 梯级编号
  ctx.fillStyle = opt.labelColor || '#a6adc8';
  ctx.font = '11px monospace';
  ctx.textAlign = 'left';
  ctx.fillText('梯级 ' + rung.number, 5, y + 15);

左母线在 x 等于 40,右母线在宽度减 40,母线高 80。左右各留 40 像素,是为了放梯级编号与右侧留白;母线只画 80 高而梯级间距是 100,中间自然留出 20 像素的空隙,视觉上梯级之间不会粘连。

导线这一段有个细节注释:

  // 导线(分段绘制:不穿过触点两竖线之间与功能块内部)
  if (rung.elements.length > 0) {
    ctx.strokeStyle = opt.wireColor || '#cdd6f4';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    var sorted = rung.elements.slice().sort(function(a, b) { return a.x - b.x; });
    var cursor = startX;
    for (var k = 0; k < sorted.length; k++) {
      var sx = startX + 60 + sorted[k].x;
      if (sx > cursor) {
        ctx.moveTo(cursor, midY);
        ctx.lineTo(sx, midY);
      }
      cursor = Math.max(cursor, sx + elemVisualWidth(sorted[k]));
    }
    if (endX > cursor) {
      ctx.moveTo(cursor, midY);
      ctx.lineTo(endX, midY);
    }
    ctx.stroke();
  }

元件按 x 坐标排序后分段连线,而不是一条长线从头拉到尾。原因很实际:触点的符号是两条短竖线中间留空,功能块是一个矩形框,如果导线一笔画过去,会从符号内部穿过去,看起来像符号裂了。分段绘制让导线止于符号边缘、再从另一侧接上,这是梯形图能不能"看着像梯形图"的分水岭。

排序用的是 slice 后的副本,没有直接对 rung.elements 排序。这一点保住了数据顺序:用户摆放元件的先后顺序在序列化后要还原,绘制顺序不能反过来污染数据。


六、对话框不用 hidden,用 .open 类加 grid 同格叠加

界面里有两类弹窗:变量编辑与关于。它们的显隐不用 hidden 属性,用类名切换。

CSS 里先定负空间:

/* ========== 对话框(grid 同格叠加) ========== */
.dialog-layer {
  display: none;
  background: rgba(0, 0, 0, 0.5);
  align-items: center;
  justify-content: center;
}
.dialog-layer.open { display: flex; }

默认 display 为 none,加上 open 类才变成 flex。遮罩层同时是居中容器,align-items 与 justify-content 一起给,弹窗盒子在遮罩里居中,不需要再用 transform 位移。

更关键的是布局方式,以及作者写在文件头的约束:

/* 逻控 PLC 编辑器 Catppuccin Mocha 深色主题 */
/* 约束:不使用绝对/固定定位、层级叠放、形变与动画(鸿蒙禁项) */

这不是事后总结,是开工时定下的红线。这一份 CSS 里数下来:

position: fixed     0 次
position: absolute  0 次
z-index             0 次
transform           0 次
animation           0 次
transition          0 次

六个统计项全是 0。浮层的显隐与覆盖,全部交给 grid:

body {
  font-family: var(--font-ui);
  font-size: 13px;
  background: var(--base);
  color: var(--text);
  height: 100vh;
  display: grid;
  grid-template-rows: auto 1fr auto;
  grid-template-columns: 1fr;
  overflow: hidden;
  user-select: none;
}
body > .toolbar { grid-area: 1 / 1; }
body > .main { grid-area: 2 / 1; }
body > .statusbar { grid-area: 3 / 1; }
body > .dialog-layer { grid-area: 1 / 1 / 4 / 2; }

工具栏占第一行、主区占第二行、状态栏占第三行;对话框的 grid-area 是 1 / 1 / 4 / 2,从第一行跨到第四行、只占第一列,正好盖住整页。后渲染的元素自然压在前面的上面,不需要 z-index。

窗口怎么缩放,行高跟着 1fr 变,遮罩跟着变,与视口尺寸无关。这是 grid 同格叠加与 fixed 的本质差别:fixed 相对视口定位,窗口边界一变就得重算;grid 把浮层与内容绑进同一个格子,单元格多大它就多大。

代价是遮罩只覆盖这一格,格外的区域它盖不到。对单窗口应用来说这正好:该盖住的是应用自己,不是屏幕。

有的项目会在遮罩上用 position: fixed,这一款一次都没有。差别不在对错,而在这一款的约束清单里明确禁了固定定位。

绑定只有三处,都在 bindAboutEvents 里:

// ========== 关于对话框(开源信息) ==========
function bindAboutEvents() {
  var layer = document.getElementById('aboutDialogLayer');
  document.getElementById('btnAbout').addEventListener('click', function() {
    layer.classList.add('open');
  });
  document.getElementById('btnAboutClose').addEventListener('click', function() {
    layer.classList.remove('open');
  });
  // 点击遮罩关闭
  layer.addEventListener('click', function(e) {
    if (e.target === layer) layer.classList.remove('open');
  });
}

第三个监听器值得说一句:点遮罩关闭,判断条件是 e.target 等于 layer 本身。如果不判断,点弹窗内部任意位置冒泡上来也会命中,用户刚点进输入框,弹窗就关了。这一行 if 就是"点里面不关、点外面才关"的全部实现。

【配图位 3:关于对话框的截图。要求同时拍到对话框与背后被遮罩压暗的主界面,能看到标题"关于 逻控 PLC 编辑器"、三段小标题(开源声明、免责声明、完全开源)与底部确定按钮。这张图对应第八节那句致谢,也是"改名之后上游名字只留一处"的现场证据。】


七、名称 12 处:资源 4、界面 3、注释与描述 5

上架名与上游名不是一个词。上游叫 OpenPLC,上架名叫逻控 PLC 编辑器。改名落点十二处:

类别数量落点
资源4AppScope 的 app_name;base、en_US、zh_CN 三套 EntryAbility_label
界面3index.html 的 L5 标题、L11 工具栏品牌、L127 关于框标题
注释与描述5main.js L1、renderer.js L1、plc-engine.js L1、openplc.css L1、package.json 的 description

四处资源的取值:

AppScope/resources/base/element/string.json     app_name            逻控 PLC 编辑器
electron/.../base/element/string.json           EntryAbility_label  逻控 PLC 编辑器
electron/.../en_US/element/string.json          EntryAbility_label  逻控 PLC 编辑器
electron/.../zh_CN/element/string.json          EntryAbility_label  逻控 PLC 编辑器

三套语言的 EntryAbility_label 写同一个值。对中文应用来说这是常见做法:名字不翻译,保持品牌一致。要翻译的只有按钮与状态栏。

三处界面的落点:

L5    <title>逻控 PLC 编辑器</title>
L11   <div class="toolbar-brand">逻控 PLC 编辑器</div>
L127  <div class="dialog-title">关于 逻控 PLC 编辑器</div>

标题栏、工具栏品牌、关于框,三处都是用户一眼能看到的位置。属性文本与窗口标题(如果有)另算,但这一款应用窗口标题取自页面 title,所以改 L5 一处就同时改了两个地方。

五处注释与描述:

main.js L1        // 逻控 PLC 编辑器 主进程
renderer.js L1    // 逻控 PLC 编辑器 渲染进程
plc-engine.js L1  // 逻控 PLC 编辑器 引擎
openplc.css L1    /* 逻控 PLC 编辑器 Catppuccin Mocha 深色主题 */
package.json      "description": "逻控 PLC 编辑器 - 鸿蒙应用(梯形图 + 结构化文本)"

四个源文件的头注释全部换名。文件名没有换:openplc.css 仍然叫 openplc,这是文件系统里的名字,用户看不到,包内也在 resfile 里,不参与显示。

这里有个容易漏的点:注释里的名字不参与界面,但参与代码审查。审核方如果抽看源码包,头注释就是第一眼。四个文件改名成本是四次替换,收益是"打开任何一个文件都看不到旧名"。结论很直接:改注释不影响签名,但改完必须重新打包。

四份 string.json 的键数并不相同,这也是模板留下的痕迹:

文件键数模板里多出来的键
AppScope/base1只有 app_name
electron/base12BrowserAbility_label、BrowserAbility_desc、StatelessAbility_label、StatelessAbility_desc
electron/en_US8无
electron/zh_CN8无

base 比另外两套多四个键,是壳模板自带的两个能力(BrowserAbility、StatelessAbility)的标签。这两个能力在本工程的包里确实存在(第十三节会看到三个能力、两个扩展),所以键保留是对的,只是取值还是模板串:

BrowserAbility_label        BrowserAbility
StatelessAbility_label      StatelessAbility
StatusBarEntryAbility_label Application status bar(en)/ 应用状态栏(zh)
OpenNewWindow_label         OpenNewWindow(en)/ 打开新窗口(zh)

这些串对应的能力不是主入口,正常使用多半碰不到。但"碰不到"不是"不存在",严格做法是把能看到的都改成正式文案。这一项进了待办,没有在本轮动,因为它不阻塞上架,改动反而要重新走一遍包内核对。


八、上游名字只留 1 处:那句致谢

改名 12 处之后,全工程还剩 1 处 OpenPLC,在 index.html 的 L130,关于对话框的开源声明里:

<p>本应用是运行于鸿蒙 PC 的 Electron 壳应用(Electron 基于 MIT 协议开源),提供梯形图(LD)编程与仿真运行环境。梯形图引擎、Canvas 2D 渲染与项目序列化均使用 JavaScript 独立实现,无第三方运行时依赖;界面设计参考 OpenPLC 等开源 PLC 项目的通用交互习惯,全部代码独立编写,不包含其任何源代码,在此谨向原作者与开源社区致谢。</p>

一句话里有四个信息,缺一个都不合适:

  1. 壳是什么:Electron 壳应用,Electron 基于 MIT;
  2. 引擎哪来的:独立实现,无第三方运行时依赖;
  3. 参考了什么:只参考交互习惯,明确"不包含其任何源代码";
  4. 态度:致谢。

另一类情况正好相反:旧名在源码里必须 0 处。两种做法不矛盾,因为名字的性质不同。如果旧名本身就是"品类加技术缩写"的组合、与上架名构成近似,那它必须彻底消失;而 OpenPLC 是一个开源项目的名字,问题不在"提到它",而在"让用户以为这是它"。

所以这一处的判断标准不是"有没有旧名",而是"有没有造成来源混淆"。现在的写法把来源说清楚了:参考交互、不抄代码、独立实现、给出致谢。这是引用声明,不是品牌露出。

配套还有两处证据,可以一起看:

  • 包内 index.html 里,新名 3 次、OpenPLC 1 次、Apache-2.0 1 次,与磁盘完全一致;
  • 六个业务文件里,OpenPLC 只在这一句出现,其余文件 0 次。

如果一个应用把上游名字从代码里删得干干净净、却在界面上照搬了对方的布局与术语,那才是真问题。反过来,代码全自写、只在声明里致谢,这一处留着比删掉更稳。

【配图位 4:工程目录里搜索 OpenPLC 的结果截图。要求显示整个工程的命中列表,只有 index.html 一行,且能看清 L130 的内容。这张图用来证明"12 处已改、只剩致谢 1 处"不是口头说法。】


九、权限 14 项:一份通用的引擎清单,一条都没动

web_engine 的 module.json5 里,requestPermissions 有 14 条,definePermissions 有 1 条:

INTERNET
GET_NETWORK_INFO
ACCESS_CERT_MANAGER
RUNNING_LOCK
PRINT
PREPARE_APP_TERMINATE
ACCESS_BIOMETRIC
PRIVACY_WINDOW
WINDOW_TOPMOST
GYROSCOPE
ACCELEROMETER
SET_ABILITY_INSTANCE_INFO
GET_FILE_ICON
LOCK_WINDOW_CURSOR

最后一条同时在 definePermissions 里再出现一次。但 LOCK_WINDOW_CURSOR 是系统权限,不是应用自定义权限,放进 definePermissions 段落里语义上并不成立,属于模板残留;清理时连它后面的尾逗号一起删掉。

文件里还有一段被注释掉的申请:

      // WEB_NATIVE_MESSAGING 是系统权限,不可在 requestPermissions 中自定义声明
      // {
      //   "name": "ohos.permission.WEB_NATIVE_MESSAGING"
      // }

这段注释解释了为什么不能申请:WEB_NATIVE_MESSAGING 属于系统权限,不能在 requestPermissions 里自定义声明。注释留着是对的,下一个接手的人不会再试一次。

真正值得注意的是这份清单的成分。把它和这个应用的功能对一遍:

权限与"梯形图编辑 + 仿真"的关系
INTERNET、GET_NETWORK_INFO应用不联网
PRINT不打印
ACCESS_BIOMETRIC、PRIVACY_WINDOW、WINDOW_TOPMOST不做指纹、不做隐私窗口、不置顶
GYROSCOPE、ACCELEROMETER桌面形态没有这两个传感器
ACCESS_CERT_MANAGER、RUNNING_LOCK、PREPARE_APP_TERMINATE、SET_ABILITY_INSTANCE_INFO、GET_FILE_ICON、LOCK_WINDOW_CURSOR引擎侧的系统能力,业务代码零入口

十四条里,能在业务代码里找到对应入口的为零。这不是巧合,而是模板基线:拷一份壳,权限清单原样带走;文件 63 行、申请 14 条、定义 1 条,本工程没有任何一条是"按需申请"出来的。

这件事的意义在于:包里的权限条数,不能用来判断这个应用要什么权限。 它反映的是壳的出身,不是应用的功能。上架时如果审核问"为什么 PLC 编辑器要申请 ACCESS_BIOMETRIC",唯一站得住的回答是"这是模板带来的,我们还没删",而不是现编一个理由。


十、这 14 条,在业务代码里 0 条对应调用

把六个业务文件放在一起,逐条找功能入口:

权限业务文件里的对应调用
INTERNET、GET_NETWORK_INFOfetch 0 处、XMLHttpRequest 0 处、WebSocket 0 处
ACCESS_CERT_MANAGERcrypto 0 处
RUNNING_LOCK0 处
PRINTprint 与 Print 均 0 处
PREPARE_APP_TERMINATE只有 window-all-closed 时的 app.quit,与权限无关
ACCESS_BIOMETRICbiometric 与 Biometric 均 0 处
PRIVACY_WINDOWprivacy 0 处(业务)、setWindowPrivacyMode 0 处(壳层)
WINDOW_TOPMOSTtopmost 与 setWindowTopmost 均 0 处
GYROSCOPE、ACCELEROMETERgyro 0 处、sensor 0 处
SET_ABILITY_INSTANCE_INFO0 处
GET_FILE_ICONfileIcon 与 FileIcon 均 0 处
LOCK_WINDOW_CURSORcursor 16 处,全部是 Canvas 画笔与 CSS 鼠标样式

两处需要说明,否则会被误读:

第一,acceler 在 main.js 里有 1 处命中,是 disableHardwareAcceleration,关硬件加速,不是加速度传感器。做关键词核对时这种同形词必须逐条看,不能只看命中数。

第二,cursor 有 16 处命中(引擎 7 处、样式 9 处),指的是画布上的光标形状与 CSS 的 cursor: pointer,跟 LOCK_WINDOW_CURSOR 这个"锁定窗口光标"权限无关。

再看壳层。electron 模块的 ArkTS 一共 18 个 ets 文件,搜这些 API:

getFileIcon              0 处
setWindowTopmost         0 处
setCursor                0 处
setWindowPrivacyMode     0 处
print                    0 处
setAbilityInstanceInfo   0 处
requestPermissions       0 处
startAbility             0 处
preferences              0 处

业务层 0 处,壳层也 0 处。

结论要说得准确:在本工程的 JS 与 ArkTS 源码里,这 14 条权限找不到一条对应调用。 但这不等于"这 14 条全都没用",因为系统框架可能在某些路径上内部使用(例如窗口图标、光标样式由框架代理),本文没有做权限拒绝态的运行时实验,所以不下"可以全删"的结论。

能做的是把顺序摆正:先做权限与功能的映射表,把"有入口"的三四条留下,其余逐条论证后删除,再重出包验证功能不缺。这个流程没走完之前,删权限比留权限风险大——删错了,功能直接不可用;留着,最多是审核多问一句。

这一条进了待办清单,位置排在签名之前。


十一、壳层 18 个 ets:最大的一份是 639 行登录组件

主进程只有 81 行,不代表壳里代码就少。把 electron 模块的 ArkTS 挨个数一遍:

文件字节行数
Application/AbilityStage.ets32911
entryability/BrowserAbility.ets1,21848
entryability/EntryAbility.ets1,14244
entryability/StatelessAbility.ets1,30349
entryability/StatusBarEntryAbility.ets1,16135
entryability/TaskManagerAbility.ets1,20644
extensionAbility/BrowserEmbeddedAbility.ets86333
pages/EmbeddedWindow.ets65325
pages/Index.ets2,60787
pages/Login.ets88135
pages/NodeHandleSubWindow.ets1,83943
pages/NodeHandleWindow.ets4,061109
pages/QuickLoginButtonComponent.ets25,241639
pages/StatusBarPage.ets30715
pages/SubWindow.ets40618
pages/WebPage.ets2,78591
pages/WindowNode.ets64125
process/CustomChildProcess.ets41215
合计47,0551,366

十八个文件、四万七千多字节里排第一的是 QuickLoginButtonComponent.ets:25,241 字节、639 行,占壳层源码字节数的 54%,行数的 47%。

它是什么?打开头两行就明白了:

import { loginComponentManager, LoginWithHuaweiIDButton } from '@kit.AccountKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { promptAction, router } from '@kit.ArkUI';
import { connection } from '@kit.NetworkKit';
import { authentication } from '@kit.AccountKit';
import { util } from '@kit.ArkTS';
import { WebPage } from './WebPage'
import { ILoginInfo } from 'web_engine';

这是华为账号快捷登录按钮组件。依赖 @kit.AccountKit,做的是拉起账号登录、拿到登录信息、回调给宿主页面。

九行 import 里,第五行是 @kit.NetworkKit 的 connection。这一行值得记下来:即便业务代码一行网络请求都没有,壳里的登录组件本身就带着网络依赖。这是 INTERNET 与 GET_NETWORK_INFO 两条权限"看起来有出处"的来源之一,也是第十节不敢下"全删"结论的实际理由。

这个应用需要登录吗?不需要。它做的是本地梯形图编辑与仿真,项目存本地 JSON,六个业务文件里没有任何账号、登录、云同步的入口。主进程的两个 IPC 也只有打开与保存文件。

再看引用关系:

QuickLoginButtonComponent.ets  <- 只被 pages/Login.ets 引用
pages/Login.ets                <- 没有被任何 ets 引用

Login.ets 里引用了它:

L5  import { ILoginInfo, ILogin } from 'web_engine';
L6  import { QuickLoginButtonComponent } from './QuickLoginButtonComponent'
...
L29 QuickLoginButtonComponent({ loginCallback: this.loginCallback_ })

而 Login.ets 自己,在十八个 ets 里找不到第二个引用者。Index.ets 不引它,EntryAbility 不引它,WebPage 也不引它。

这就解释了本篇开头那个反差:主进程 81 行是真的,壳里躺着 639 行没人调用的登录组件也是真的。 两件事同时成立,因为它们不在同一个目录:一个在 resfile 的 app 里(业务),一个在 electron 的 ets 里(模板)。

要强调的是,这里不说它是"死代码"。ArkTS 的编译行为是把模块内所有源文件编进字节码,是否被引用不决定是否进包。第十四节会给出直接证据:这个组件的名字在包内的 modules.abc 里出现 107 次。它在包里,只是不在界面上。

这类残留的处置不宜一刀切。删掉 Login.ets 与 QuickLoginButtonComponent.ets 能省字节,但风险在于:WebPage 或其他 ets 可能通过字符串路由、动态 import 引用它们,静态 grep 看不出来;删完必须整包回归。更稳的顺序是先确认它没有出现在任何 module.json5 的 pages 或路由配置里,再删。

本工程没有动这两个文件。理由是:它们不影响界面、不影响权限、不影响签名,属于"技术上该清、但不在本次上架阻塞项里"的一类。


十二、签名:又是 debugKey

build-profile.json5 的签名段,脱敏后是这样:

    "signingConfigs": [
      {
        "name": "default",
        "type": "HarmonyOS",
        "material": {
          "certpath": "C:\\Users\\<you>\\.ohos\\config\\default_ohos_hap_...=.cer",
          "keyAlias": "debugKey",
          "keyPassword": "0000001B...(共 86 字符,已截断)",
          "profile": "C:\\Users\\<you>\\.ohos\\config\\default_ohos_hap_...=.p7b",
          "signAlg": "SHA256withECDSA",
          "storeFile": "C:\\Users\\<you>\\.ohos\\config\\default_ohos_hap_...=.p12",
          "storePassword": "0000001B...(共 86 字符,已截断)"
        }
      }
    ]

四个判断点:

项值含义
keyAliasdebugKey别名还是调试默认值
三件套.p12、.p7b、.cer 都在 .ohos\config 下用的是 DevEco 自动生成的调试证书
两个密码均 86 字符、均以 000000 开头与调试证书配套的默认口令格式
文件时间2026-10-27 12:23:33这份配置最后一次被改动的时间

这一套是 DevEco 新建工程时自动生成的默认调试证书:别名 debugKey,口令是自动生成的长串,三件套都落在 .ohos\config 下。文件名的随机段是 DevEco 为本工程生成调试材料时留下的,不能跨工程复用。

调试证书不是不能出包,它能出、能装、能跑。问题在提审:正式上架要用发布证书与正式 Profile,调试证书签出来的包不具备上架资格。这一条在 AGC 提交环节会直接卡住,不是"建议",是硬门槛。

时间线也值得看一眼:

build-profile.json5   2026-10-27 12:23:33   ← 配置最后一次改动
signed hap            2026-10-27 12:23:58   ← 25 秒后出包
.app(提审物)        2026-10-14 15:28:29   ← 13 天前

配置在 10-27 中午改过一次,25 秒后就出了一版签名 hap。但那个 hap 不是提审物,提审物是 13 天前打的 .app。这个错位值得单独记一笔:改配置之后必须重出包,旧包不会自己变新。


十三、提审包:86.9 MB、release 构建、28 个条目

拆开 build/outputs/default 下的产物:

项值
ohos_hap-default-signed.app86,902,599 B(2026-10-14 15:28:29)
包内 hapelectron-default.hap,193,805,041 B,crc=af5a427f
bundleNamecom.wei.luokongplc
versionCode / versionName1000000 / 1.0.0
label$string:app_name
minAPIVersion / targetAPIVersion50005017 / 60000020
构建模式buildMode 为 release,debug 为 false
权限14 条
能力3 个
扩展能力2 个
包内条目28 个
ets/modules.abc912,608 B
.pages.info缺失
ets/sourceMaps.map缺失

构建模式是 release。 这一条要单独说,因为它和权限、签名是三件不同的事:

  • 构建模式决定产物里有没有调试信息(.pages.info、sourceMaps、未裁剪的字节码);
  • 权限决定系统给不给能力;
  • 签名决定能不能上架。

这三件事混在一起看最容易误判:包是 release 构建,但签名配置停在 debugKey,10-27 又出了一版 debug 构建的 hap。分开记就清楚了:

10-14 的 .app      release 构建   调试证书签名   ← 提审物
10-27 的 signed hap debug 构建    调试证书签名   ← 不是提审物

10-27 那版 hap 的包里能直接看到证据:条目 30 个,.pages.info 21,384 字节,ets/sourceMaps.map 572,033 字节,modules.abc 1,499,320 字节,buildMode 字段写的是 debug、debug 为 true。同一份壳代码,debug 版的字节码比 release 版大 64%,多出来的就是调试信息。

包内条目 28 个,.pages.info 与 sourceMaps 都不在里面,这是 release 构建的正常形态。反过来,debug 构建一定多出这两个条目——本工程 10-27 那版 hap 就是 30 个。这个差值可以直接当构建模式的判据用,比读 buildMode 字段更直观:字段可能被改,条目不会说谎。

包内六文件与磁盘的比对:

index.html      相同
main.js         相同
renderer.js     相同
plc-engine.js   相同
styles/openplc.css  相同
package.json    相同
六个文件逐字节相同 6 个

六个全中,说明这次出包确实用的是当前仓库代码。这一步看着像废话,但这套工程里提审物与代码错位是常态:本包的 .app 是 10-14 打的,build-profile 到 10-27 才改过。所以每次核对包,第一件事都是比这六个文件。

包内 index.html 里的名字分布也对得上:

逻控 PLC 编辑器   3 次
OpenPLC           1 次
Apache-2.0        1 次
PLC               5 次

新名 3 次(标题、品牌、关于框),上游名 1 次(致谢句),许可证 1 次。与磁盘一致,无遗漏、无残留。

三个能力与两个扩展,名字与入口都能对上:

EntryAbility              ./ets/entryability/EntryAbility.ets          图标 $media:app_icon
BrowserAbility            ./ets/entryability/BrowserAbility.ets        无图标
StatelessAbility          ./ets/entryability/StatelessAbility.ets      无图标
StatusBarEntryAbility     statusBarView
BrowserEmbeddedAbility    embeddedUI

主入口 EntryAbility 带图标,另外两个能力不带。状态栏与内嵌浏览器页各占一个扩展能力,都是壳的能力位,本应用没有主动调用。


十四、912,608 字节里,装的是谁

这是本篇最硬的一段证据。

从提审的 .app 里取出 hap,再取出 ets/modules.abc,912,608 字节。直接在这个二进制文件里做字节串匹配:

搜索串命中次数说明
QuickLoginButtonComponent107登录组件,进了包
LoginWithHuaweiIDButton9华为账号登录按钮,进了包
ladderCanvas0梯形图画布 id,没进包
file:open0主进程 IPC 通道名,没进包
file:save0主进程 IPC 通道名,没进包
openplc0业务文件名前缀,没进包
luokongplc1,587包名,进了包
web_engine1,423模块名,进了包
EntryAbility13入口能力,进了包

前两组数字放在一起,就是结论:

业务代码一行都没进字节码,登录组件进了 107 次。

原因在编译范围。ArkTS 的字节码只收 ets/ts 源文件与它们依赖的模块;resfile 下的 JS、HTML、CSS 是资源,原样塞进包,不参与编译。所以:

  • 六个业务文件(55,301 字节)在 resources/resfile/resources/app 下,原样在包里;
  • 十八个 ets(47,055 字节)与它们依赖的 ohpm 包(例如 inversify)编成 modules.abc;
  • 两者在包里是两套东西,互不影响。

【配图位 5:二进制搜索结果的终端截图。要求在解包后的 ets/modules.abc 上执行搜索,一屏内同时看到 QuickLoginButtonComponent 命中 107 次与 ladderCanvas 命中 0 次。这张图是第十四节的硬证据,缺了它,前文那句"业务代码不进字节码"就只是推断。】

这也解释了另一个现象:这套壳的字节码尺寸会稳定在一个很窄的区间里。本工程 912,608 字节,18 个 ets 合计 47,055 字节,其余全是它们依赖的 ohpm 模块与框架代码;业务代码从 55,301 字节再涨,这个数字也不会变。

反过来,同一个工程换构建模式,尺寸会明显跳:10-27 那版 debug hap 的 modules.abc 是 1,499,320 字节,比 release 版多 64%,多出来的就是 .pages.info、sourceMaps 与未裁剪的调试信息。

10-14  .app 内 modules.abc        912,608 B   release
10-27  signed hap 内 modules.abc 1,499,320 B  debug

所以尺寸这条基线有两个用法:同一构建模式前后比,尺寸变了说明壳被动过;跨构建模式比,尺寸差就是调试信息的量。业务代码多寡不参与这个数字,这也是本篇开头那个反差的另一半。


十五、图标:546,647 字节,三件同哈希

AppScope/resources/base/media 下三个文件:

app_icon.png          546,647 B  sha256 e66a1fde62574dfb34dc4c1e...
product_logo_32.png   546,647 B  sha256 e66a1fde62574dfb34dc4c1e...
startIcon.png         546,647 B  sha256 e66a1fde62574dfb34dc4c1e...

三件套字节数完全相同、哈希完全相同:同一张图复制了三份、换了三个文件名。

同时,electron 模块下没有 media 目录:

electron/src/main/resources/base/media 存在 False

这是这套壳的标准形态:图标只放 AppScope 一处,模块级不覆盖,三个位置用同一张源图。

三个文件名各自有用:

  • app_icon 是应用图标,包内 EntryAbility 的 icon 字段指向 $media:app_icon;
  • startIcon 是启动页图标;
  • product_logo_32 是较小的产品标识位。

三个都在,且内容一致,说明改图标时是一次性替换三份。这比"三份不同图"更省心:任何一处显示不一致的投诉,都可以从哈希相同这一点上排除。

三件套同字节同哈希、模块目录为空、全链回落到 AppScope,这是壳模板的固定行为,不是某个工程的个案。


十六、待办与边界

按优先级排,六条:

  1. 签名换 Release,这是硬门槛。当前 build-profile.json5 是 debugKey 加默认调试证书三件套,提审前必须换成发布证书与正式 Profile,换完重出包,重新核对包内六文件与权限条数。10-27 那版 hap 不能直接当提审物。
  2. 权限做减法,先出映射表。14 条里在业务与壳层都找不到入口的,逐条论证后删除;有入口的留下并在提交说明里写清用途。删完必须整包回归(打开、保存、画布交互、窗口缩放)。
  3. 模板残留清理,Login.ets 与 QuickLoginButtonComponent.ets 两份共 674 行。确认无动态引用后再删,删完看 modules.abc 的尺寸变化,再决定是否值得。
  4. 四份 string.json 的模板串,BrowserAbility_label、StatelessAbility_label、StatusBarEntryAbility_label、OpenNewWindow_label 还是模板值。不阻塞上架,但属于"打开设置页可能看到英文"的隐患。
  5. 真机截图补齐,首图已补入,剩余四个配图位需要真机环境出图,尤其是关于对话框那张。
  6. 包与代码的对应关系固化成流程,每次出包后自动比对包内六文件与磁盘、比对权限条数、记录 modules.abc 尺寸与哈希。这一条做完,前面五条里的"核对"就不再依赖人眼。

边界也要说清楚,免得读者把结论用过头:

  • 没有做权限拒绝态的运行时实验。 本文只证明了源码里没有调用入口,没有证明系统不会用到这些权限,所以不下"14 条可以全删"的结论。
  • 没有反编译 modules.abc。 登录组件的 107 次命中是字节串匹配的结果,能证明"名字进了包",不能算出它占了多少字节。54% 这个比例是源文件字节占比,不是字节码占比。
  • 没有用 hap-sign-tool 验签历史包。 提审物的签名信息来自配置文件与产物时间线,没有做证书链校验。
  • .app 与 10-27 那版 hap 的关系没有定论。 10-27 的 hap 没有打进 .app,两者是两次独立构建。至于为什么要重新出一版,工程里没有留下记录,本文不猜。
  • 业务代码规模只统计了本工程的 6 个业务文件。 不含 ohpm 依赖与 ets 源码,也不把它与字节码尺寸做因果关联。

最后一句话留给这套壳的判断方法。这次核对下来,最有用的一条不是权限条数,也不是签名别名,而是那个 912,608:

业务代码不进字节码,所以字节码的尺寸只反映壳。 壳的尺寸相同、哈希不同,说明两个包是同一套模板换了名字;壳的尺寸变了,说明壳被动过。把这条当基线,下次再看到包体涨了几 MB,先看 resfile 里多了什么,而不是去翻 ets。

把这几条串起来看,这套壳的核对方法其实只有一句:先分清哪些数字来自壳、哪些来自业务,再决定拿哪个数字当判据。 权限条数与字节码尺寸都来自壳,业务代码的规模则在 resfile 里原样躺着——把这三件事分开记,下次再遇到包体异常或权限质疑,排查方向就不会跑偏。

如果你也在走 Electron 壳方案上鸿蒙桌面端,欢迎加入文首的开源鸿蒙 PC 社区,一起交流更多踩坑经验。

Logo

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

更多推荐