鸿蒙 PC 桌面端应用上架实战:自称 8 种算子的遥感工具,注册表里躺着 18 个,安装包 86% 是一个 .so 文件
鸿蒙 PC 桌面端应用上架实战:自称 8 种算子的遥感工具,注册表里躺着 18 个,安装包 86% 是一个 .so 文件
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_OrfeoToolBox



这个应用叫星图遥感。它能做的事不少:打开一张遥感图像,从七组算子库里挑算子、配参数、串成处理链,一步一步算出结果,右侧同步画出直方图,点击图像还能读出任意像素的 RGB。
但它的体积构成很反常。
业务代码六个文件,加起来 78,285 字节:
- index.html 5,145 字节
- main.js 2,811 字节
- renderer.js 25,324 字节
- otb-engine.js 33,506 字节
- styles/otb.css 11,277 字节
- package.json 222 字节
提审的 .app 是 87,009,906 字节。把外层解开,里面只有一个 hap,193,930,499 字节;再把 hap 解开,28 个条目,解压后合计 193,926,507 字节。
其中 libs 目录 173,609,328 字节,占 89.53%。最大的一个条目是 libs/arm64-v8a/libelectron.so,167,471,912 字节,占解压总量的 86.36%。
更微妙的是它的压缩方式:这个 167 MB 的文件在 hap 里以 store 方式存放,压缩前后字节数完全相同;真正把它压到 87 MB 的,是外面那层 .app。整个 hap 从 193,930,499 压到 86,993,159,压掉了 55.1%。
而 78,285 字节的业务代码,占解压总量的 0.040%。
另一个反常之处在文案上。关于对话框写着"提供遥感图像浏览、8 种像素算子处理链与直方图统计功能",引擎文件第 2 行也写着"8 种遥感/图像处理算子"。可注册表里数一遍:OP_FUNCTIONS 18 个键,OP_DEFS 18 条定义,分七组。
还有一处对不上。这个工程的 Electron 壳,18 个 ets 文件,47,055 字节,1,355 行,编译出来的 ets/modules.abc 却只有 915,812 字节——壳的总行数大约 1,355 行 ArkTS,产物不到 1 MB。而 78 KB 的业务 JS 一个字节都没进这份字节码,它躺在 resources/resfile 里当资源。壳与业务,是两条完全分开的链路。
这三组数字就是本篇要讲的事:包体的大头在运行时,不在业务;关于对话框停在 8,注册表已经翻到 18;壳与业务分属两条链路,字节码只装壳。
先把边界划清楚。这个工程已经通过了上架审核,本篇不复述审核过程,也不铺开签名配置、权限清单、产物三分法这些通用流程。本篇只做一件事:把这个包拆开,一个条目一个条目地量,量完再回头对文案。
本篇回答三个问题:
- 78 KB 的业务代码,怎么装出 87 MB 的包,重量到底压在哪个条目上;
- 关于对话框说 8 种算子,注册表里为什么是 18 个,多出来的 10 个是谁;
- 78 KB 的业务代码为什么一个字节都不进字节码,那 915,812 字节里装的到底是什么。
一、工程身份:一个中文名,四种落点
先把这个应用的身份信息摆齐。
AppScope/app.json5 里:
- bundleName:com.wei.xingtuyaogan
- vendor:wei
- versionCode:1000000
- versionName:1.0.0
包的 module.json 里还有几项 app.json5 不写的:
- compileSdkVersion:6.0.0.47
- minAPIVersion:50005017
- targetAPIVersion:60000020
- apiReleaseType:Release
- multiAppMode:multiInstance,maxCount 1
- buildMode:release,debug:false
构建配置 build-profile.json5 里:
- compatibleSdkVersion:5.0.5(17)
- compatibleSdkVersionStage:release
- runtimeOS:HarmonyOS
- signingConfig:default
模块级 module.json5 里,deviceTypes 是 tablet 与 2in1,module type 是 entry,mainElement 是 EntryAbility。
应用名落在四处,四条都是"星图遥感":
- AppScope 的 string.json,1 个键 app_name
- electron 模块 base 的 string.json,12 个键,其中 EntryAbility_label 是星图遥感
- en_US 的 string.json,8 个键,EntryAbility_label 也是星图遥感
- zh_CN 的 string.json,8 个键,EntryAbility_label 还是星图遥感
这个工程里搜不到 OrfeoToolBox,也搜不到 OTB。六个业务文件里:
- 星图遥感:index.html 3 次,main.js 1 次,renderer.js 1 次,otb-engine.js 1 次,otb.css 1 次,package.json 1 次
- OrfeoToolBox:0 次
- OTB:0 次
- xingtu:只有 package.json 的 name 字段 1 次
文件名里倒是留着 otb 三个字母:otb-engine.js、otb.css。这是内部文件名,不上桌面、不进应用信息页,审核不查这一层。
倒是 index.html 第 96 行留了一句致谢,把上游名字点了出来:
<p>本应用是运行于鸿蒙 PC 的 Electron 壳应用(Electron 基于 MIT 协议开源),提供遥感图像浏览、8 种像素算子处理链与直方图统计功能。全部图像处理算法(NDVI/NDWI/EVI/SAVI/Otsu 阈值/边缘检测/K-Means 聚类/CLAHE 对比度自适应直方图均衡)均使用 JavaScript 独立实现;交互设计参考 Orfeo ToolBox 等开源遥感项目的通用使用习惯,全部代码独立编写,不包含其任何源代码,在此谨向原作者与开源社区致谢。</p>
这段话里有两层信息。第一层是免责:算法独立实现,只参考交互习惯。第二层是那句"8 种像素算子"——它后面括号里列了 8 个名字:NDVI、NDWI、EVI、SAVI、Otsu、边缘检测、K-Means、CLAHE。
也就是说,这句文案的 8 是"括号里列举了 8 个具名的"。可注册表里不止这 8 个。这个问题留到第六节展开。
【配图位 2:应用信息页与桌面图标截图。左侧显示应用名"星图遥感"与包名 com.wei.xingtuyaogan,右侧是桌面图标与任务栏图标。用来证明改名之后的对外身份已经干净,工程内部文件名里的 otb 不外露。】
二、六个文件量一遍:2,339 行
六个文件合计 78,285 字节、2,339 行。
| 文件 | 字节 | 行数 | 占业务体积 |
|---|---|---|---|
| index.html | 5,145 | 111 | 6.6% |
| main.js | 2,811 | 93 | 3.6% |
| renderer.js | 25,324 | 660 | 32.3% |
| otb-engine.js | 33,506 | 919 | 42.8% |
| styles/otb.css | 11,277 | 548 | 14.4% |
| package.json | 222 | 8 | 0.3% |
| 合计 | 78,285 | 2,339 | 100% |
六个文件的修改时间集中在 2026-10-14 23:17:49 到 23:17:52 这四秒里。也就是说,最后一次整体落盘是一次性完成的,不是逐文件慢慢改出来的。
这个时间点很重要。产物 ohos_hap-default-signed.app 的时间是 2026-10-14 23:27:44,比源码晚 9 分 52 秒。也就是先改完代码,不到十分钟就打了包。
每个文件都是 CRLF 换行,换行数与行数一一对应,没有混用。
结构分工很清楚:
- main.js 管窗口与文件 IPC,93 行;
- renderer.js 管界面状态、算子库 UI、处理链、直方图、探针、缩放、导出,660 行;
- otb-engine.js 管像素运算,919 行,30 个函数,0 个 require;
- index.html 只搭骨架,111 行,没有任何内联脚本;
- otb.css 是深色主题与三栏布局,548 行。
业务代码占包体 0.040%。这个数字后面还要用到。
三、主进程 93 行:四条 IPC 全给文件
先看开头。main.js 前 40 行:
// 星图遥感 主进程
// 职责:窗口管理 + 图像文件打开/读取/保存/写入 IPC(真实 fs,base64 传输)
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');
app.disableHardwareAcceleration();
// 移除应用级默认菜单栏(File/Edit/View/Window/Help)
Menu.setApplicationMenu(null);
var mainWindow = null;
var MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB 上限
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(); });
93 行里,前 40 行干完了三件事:关硬件加速、拆菜单、按屏幕工作区的 95% × 90% 开窗。
窗口参数里有两条值得记下来:
- nodeIntegration: true、contextIsolation: false,渲染进程直接 require(‘electron’),这是老式 Electron 写法,也是这个模板能让业务 JS 直接跑起来的前提;
- frame: true,窗口边框保留,交给系统画,不自绘。
后面 53 行,四条 IPC 通道,全是文件:
- dialog:openImage:弹打开对话框,过滤 png/jpg/jpeg/bmp
- fs:readImage:读文件,先查大小上限,再转 base64
- dialog:saveImage:弹保存对话框,默认文件名 otb-result.png
- fs:writeImage:把 base64 解成 Buffer,写盘
这四条是全应用仅有的跨进程调用。渲染进程不直接碰文件系统,所有磁盘操作都收在主进程里。
打开与读取是两条独立的通道,不是一条。这个设计有意义:对话框只负责拿路径,读文件只负责拿内容,两件事失败的原因不同,状态栏给的提示也不同。
【配图位 3:主进程 93 行的编辑器截图,左侧行号 1-93 全露,滚到中段能同时看到 L18 的 20 MB 上限、L29 的 nodeIntegration 与 L45 的 dialog:openImage。证明主进程真的只有 93 行。】
四、20 MB 上限与 base64 的 33%
读文件这一段,把上限写进了第一条判断:
// 打开图像对话框
ipcMain.handle('dialog:openImage', function() {
return dialog.showOpenDialog(mainWindow, {
title: '打开遥感图像',
filters: [
{ name: '图像文件', extensions: ['png', 'jpg', 'jpeg', 'bmp'] }
],
properties: ['openFile']
});
});
// 读取图像为 base64(供 img 标签加载)
ipcMain.handle('fs:readImage', function(event, filePath) {
try {
var stat = fs.statSync(filePath);
if (stat.size > MAX_IMAGE_BYTES) {
return { success: false, error: '文件超过 20 MB 上限' };
}
var buf = fs.readFileSync(filePath);
return {
success: true,
base64: buf.toString('base64'),
size: stat.size
};
} catch (e) {
return { success: false, error: e.message };
}
});
写盘是反过来的:
// 写入图像文件(base64 → Buffer → 文件)
ipcMain.handle('fs:writeImage', function(event, filePath, base64) {
try {
var buf = Buffer.from(base64, 'base64');
fs.writeFileSync(filePath, buf);
return { success: true, size: buf.length };
} catch (e) {
return { success: false, error: e.message };
}
});
两条通道的载荷都是 base64 字符串。这是本篇第一个要算的账。
base64 的膨胀率是 4/3,也就是 33.3%。一张 20 MB 的 PNG,读进来变成约 26.7 MB 的字符串,经 IPC 从主进程拷到渲染进程,渲染进程再拼成 data:image/png;base64, 交给 Image 解码。
导出方向更重:画布先 toDataURL(‘image/png’) 编成 base64,字符串再经 IPC 回到主进程,Buffer.from 解回二进制,最后落盘。一张 4096×4096 的 RGBA 图,原始像素是 67,108,864 字节,即 64 MB;编成 PNG 之后体积看内容,但字符串那一步至少再乘 1.33。
这就是为什么 20 MB 是文件上限,不是像素上限。文件上限卡的是磁盘上的字节,像素上限卡的是解码后的内存。两道闸门管两件事,第四节先讲第一道,第九节讲第二道。
顺带一句:这个上限写在主进程,渲染进程拿不到——它只知道结果,返回 success: false 时把 error 原文写进状态栏。错误文案是"文件超过 20 MB 上限",用户看到的就是这一句。
五、引擎 919 行:30 个函数,0 个 require
把 otb-engine.js 的函数名列出来:
- 结构工厂:createImageData、cloneImageData
- 基础工具:luma、clamp255
- 像素算子:toGrayscale、invert、sobelEdge、gaussianBlur、otsuThreshold、ndvi、brightnessContrast、normalize、medianFilter、morphology、dilate、erode、cannyEdge、kmeansClassify、ndwi、evi、savi、histogramEqualize、clahe
- 参数清洗:clampIntParam、clampFloatParam、bandOffsetParam
- 统计:grayStats、histogram
- 调度:applyChain、validateImage
30 个函数名,全部是顶层 function 声明,没有类,没有模块包装,没有 require。
文件的全部依赖就是浏览器自带的类型:
// 星图遥感 引擎
// 职责:8 种遥感/图像处理算子(全部真实像素运算)+ 处理链重放 + 直方图统计
// 数据结构:{ width, height, data: Uint8ClampedArray }(与浏览器 ImageData 同形)
// ========== 结构工厂与克隆 ==========
function createImageData(width, height) {
return {
width: width,
height: height,
data: new Uint8ClampedArray(width * height * 4)
};
}
数据结构这一条很关键:它和浏览器的 ImageData 同形。所以 renderer 里 ctx.getImageData 拿到的东西可以原样丢进引擎,引擎算完的结果也可以直接 ctx.putImageData 画回去,中间不需要任何转换层。
灰度与钳位是两个最小的公共件:
var BAND_OFFSET = { R: 0, G: 1, B: 2 };
// BT.601 灰度
function luma(r, g, b) {
return 0.299 * r + 0.587 * g + 0.114 * b;
}
function clamp255(v) {
return v < 0 ? 0 : (v > 255 ? 255 : v);
}
BT.601 的系数是 0.299 / 0.587 / 0.114。这是整个引擎里唯一一处与色彩空间有关的常量,其余 17 个算子全部在 0 到 255 的整数域里算,不涉及 Gamma、不做线性化,也不做色彩管理。选哪套系数只影响灰度化一个算子的输出,不影响其他算子。
引擎文件里没有一行 fetch、没有 XMLHttpRequest、没有 WebSocket、没有 localStorage。整个 919 行是纯函数集合,输入 ImageData,输出 ImageData。判据很干净:把 otb-engine.js 单独拿出来,在 Node 里 require 也能跑,因为尾部留了模块导出。
六、自称 8 种,注册表里 18 个
现在来处理那个对不上的数字。
先看注册表:
// ========== 算子注册表 ==========
var OP_FUNCTIONS = {
grayscale: toGrayscale,
invert: invert,
sobel: sobelEdge,
gaussian: gaussianBlur,
otsu: otsuThreshold,
ndvi: ndvi,
bc: brightnessContrast,
normalize: normalize,
median: medianFilter,
dilate: dilate,
erode: erode,
canny: cannyEdge,
kmeans: kmeansClassify,
ndwi: ndwi,
evi: evi,
savi: savi,
equalize: histogramEqualize,
clahe: clahe
};
18 个键。再看定义表的第一段,基础增强组:
// 算子定义表(renderer 构建分组算子库与参数 UI)
var OP_DEFS = [
// 基础增强
{ id: 'grayscale', name: '灰度化', group: '基础增强', params: [] },
{ id: 'invert', name: '反色', group: '基础增强', params: [] },
{ id: 'bc', name: '亮度/对比度', group: '基础增强',
params: [
{ key: 'brightness', label: '亮度 (-100~100)', type: 'number', def: '0' },
{ key: 'contrast', label: '对比度 (-100~100)', type: 'number', def: '0' }
] },
{ id: 'normalize', name: '直方图拉伸', group: '基础增强', params: [] },
{ id: 'equalize', name: '直方图均衡', group: '基础增强', params: [] },
{ id: 'clahe', name: 'CLAHE 自适应均衡', group: '基础增强',
params: [
{ key: 'tiles', label: '每轴块数', type: 'seg', options: ['4', '8', '16'], def: '8' },
{ key: 'clip', label: '限幅倍率 (1~20)', type: 'number', def: '2.5' }
] },
再看最后一段,光谱指数与分类:
// 光谱指数
{ id: 'ndvi', name: 'NDVI 植被指数', group: '光谱指数',
params: [
{ key: 'nir', label: 'NIR 通道', type: 'seg', options: ['R', 'G', 'B'], def: 'G' },
{ key: 'red', label: 'Red 通道', type: 'seg', options: ['R', 'G', 'B'], def: 'R' }
] },
{ id: 'ndwi', name: 'NDWI 水体指数', group: '光谱指数',
params: [
{ key: 'green', label: 'Green 通道', type: 'seg', options: ['R', 'G', 'B'], def: 'G' },
{ key: 'nir', label: 'NIR 通道', type: 'seg', options: ['R', 'G', 'B'], def: 'B' }
] },
{ id: 'evi', name: 'EVI 增强植被', group: '光谱指数',
params: [
{ key: 'nir', label: 'NIR 通道', type: 'seg', options: ['R', 'G', 'B'], def: 'G' },
{ key: 'red', label: 'Red 通道', type: 'seg', options: ['R', 'G', 'B'], def: 'R' },
{ key: 'blue', label: 'Blue 通道', type: 'seg', options: ['R', 'G', 'B'], def: 'B' }
] },
{ id: 'savi', name: 'SAVI 土壤调节', group: '光谱指数',
params: [
{ key: 'nir', label: 'NIR 通道', type: 'seg', options: ['R', 'G', 'B'], def: 'G' },
{ key: 'red', label: 'Red 通道', type: 'seg', options: ['R', 'G', 'B'], def: 'R' }
] },
// 图像分类
{ id: 'kmeans', name: 'KMeans 聚类', group: '图像分类',
params: [{ key: 'k', label: '类别数 K', type: 'seg', options: ['2', '3', '4', '6', '8'], def: '3' }] }
];
七组,18 个:
- 基础增强:灰度化、反色、亮度/对比度、直方图拉伸、直方图均衡、CLAHE 自适应均衡(6 个)
- 滤波去噪:高斯模糊、中值滤波(2 个)
- 边缘检测:Sobel 边缘、Canny 边缘(2 个)
- 形态学:形态学膨胀、形态学腐蚀(2 个)
- 二值化:Otsu 二值化(1 个)
- 光谱指数:NDVI、NDWI、EVI、SAVI(4 个)
- 图像分类:KMeans 聚类(1 个)
6 + 2 + 2 + 2 + 1 + 4 + 1 = 18。
那句"8 种"的 8,正好是致谢括号里点名的 8 个:NDVI、NDWI、EVI、SAVI、Otsu、边缘检测、K-Means、CLAHE。多出来的 10 个是灰度化、反色、亮度/对比度、直方图拉伸、直方图均衡、高斯模糊、中值滤波、Sobel、膨胀、腐蚀。
这不是错误,是口径。文案数的是"有专名的遥感算法",注册表数的是"用户能在左侧面板里点到的算子"。从用户体验看,18 是对的;从营销文案看,8 是保守的。
但我倾向于把它当成一个待办:一句写在关于对话框里的话,用户是会逐字读的。他说 8 种,界面里能点 18 个,这中间没有解释。下一版要么把文案改成 18,要么把 10 个基础算子归到"预处理"里不计数。无论哪种,都比现在这样悬着好。
顺便记一笔:这 18 个算子里,12 个带参数面板,6 个不带(灰度化、反色、直方图拉伸、直方图均衡、Sobel、Otsu)。参数类型只有两种:number 与 seg(分段按钮)。参数项最多的是 EVI,三条通道选择。没有滑块,没有开关,没有文本框。参数 UI 是 renderer 里遍历 OP_DEFS 生成的,不是手写 18 套 DOM——改一个算子的参数,只改定义表里那几行。
【配图位 4:左侧算子库截图。要求七组全展开,能数出 18 个算子条目,并把顶部"算子库"标题与底部"+ 添加到处理链"按钮一起拍进去。这张图是"18 个"的现场证据,与关于对话框那句"8 种"形成对照。】
七、applyChain:一个算子失败,不拖垮整条链
处理链是数组,每一步是 {op, params}。执行它的函数是 25 行:
// ========== 处理链重放(算子异常不中断整链,失败步骤跳过并记录) ==========
function applyChain(source, chain) {
var current = cloneImageData(source);
var log = [];
for (var i = 0; i < chain.length; i++) {
var step = chain[i];
var fn = OP_FUNCTIONS[step.op];
if (!fn) {
log.push({ op: step.op, error: '未知算子' });
continue;
}
var t0 = Date.now();
try {
current = fn(current, step.params || {});
} catch (e) {
log.push({ op: step.op, error: (e && e.message) ? e.message : '算子执行失败', ms: Date.now() - t0 });
continue; // 失败步骤跳过,链上后续算子继续在当前图像上执行
}
var meta = current.otbMeta || null;
log.push({ op: step.op, ms: Date.now() - t0, meta: meta });
var clean = { width: current.width, height: current.height, data: current.data };
if (current.otbMeta) clean.otbMeta = current.otbMeta;
current = clean;
}
return { image: current, log: log };
}
三个设计点。
第一,入口先 clone。source 是渲染进程里那张原图的 ImageData,不 clone 的话,某些原地修改的算子会把原图改掉,用户切回"原图"视图看到的就不是原图了。
第二,失败继续。catch 里 continue,错误写进 log 的 error 字段,后面照跑。对一条六步的链来说,第二步炸了,第三到第六步还在上一步的输出上继续算。对用户来说,看到的是"结果出来了,但第二步标红",而不是"整条链没了"。
第三,每个算子的返回值里带 otbMeta 的,会被摘出来放进 log,再把图像对象洗成干净的 {width, height, data}。otbMeta 装的是这一步的附加信息(比如阈值、类别中心),用于日志展示;洗掉它是为了让下游算子拿到的输入结构永远是那三个字段,不会有算子依赖上游私有字段。
日志条目长这样:{op, ms, meta} 或 {op, error, ms}。渲染进程把 log 画成右侧那列处理日志,成功的绿色,失败的红色。
这个设计在上架语境下有额外好处:审核员随手点几个算子,就算点到一个参数填错的组合,界面也不会白屏,只会多一行红字。稳定性不是靠不炸,是靠炸了还能用。
八、增量执行:只在链尾追加时省一次全链
renderer 里真正决定"要不要重算"的是这一段:
// 实际执行:链尾追加时直接在上次结果上只算新算子(增量);其余情形全链重算
function executeChain() {
if (!state.sourceData) return;
if (state.chain.length === 0) {
state.resultData = null;
state.logEntries = [];
state.totalMs = 0;
renderLog();
el.statusTime.textContent = '-';
updateView();
renderInfo();
setStatus('处理链已清空,显示原图');
return;
}
var appendMode = state.resultData && state.cacheSteps === state.chain.length - 1;
var t0 = Date.now();
var result;
try {
result = appendMode
? applyChain(state.resultData, [state.chain[state.chain.length - 1]])
: applyChain(state.sourceData, state.chain);
} catch (e) {
setStatus('处理链执行异常: ' + errText(e));
state.cacheSteps = -1;
return;
}
var elapsed = Date.now() - t0;
state.resultData = result.image;
state.cacheSteps = state.chain.length;
判断条件只有一个:cacheSteps 是否等于当前链长减一,且 resultData 存在。成立就是"链尾刚追加了一步",只需要把新的那一步套在上一次结果上;不成立就整链从头跑。
为什么这么设计?因为最常见的操作是往链尾加一步看效果,其次是删掉中间某一步重来。前者能省掉前面所有步,后者必须全算。
有一个细节值得注意:cacheSteps 初值是 -1,空链时 length 是 0,-1 不等于 0-1,所以打开新图时第一次添加算子,appendMode 是 false,走全链。全链只有一步,代价一样。之后的追加才是真增量。
同一个文件里还有第二道防抖:
// 状态栏先行反馈让用户知道已响应;runPending 合并快速连点,避免重复重算
function runChain() {
if (!state.sourceData) return;
var mp = (state.sourceData.width * state.sourceData.height / 1000000).toFixed(1);
setStatus(state.cacheSteps === state.chain.length - 1 && state.resultData
? '计算中…(增量执行最后一步)'
: '计算中…(' + mp + ' 百万像素 × ' + state.chain.length + ' 步,请稍候)');
if (state.runPending) return;
state.runPending = true;
setTimeout(function() {
state.runPending = false;
executeChain();
}, 30);
}
30 毫秒内的连点合并成一次。状态栏在真正开算之前就已经写了"计算中",用户点完按钮立刻有反馈,不会以为没点上又点一次。
这两处加起来,是这个应用在"没有 Worker"的前提下能做到的最好。工程里搜 Worker 是 0 次,像素运算全在主线程,界面会卡,但卡的时间被控制在"一次必要的计算"上。
九、两道闸门:20 MB 与 4096×4096
第一道在第三节:文件字节数,20 MB。
第二道在 renderer 第 7 行:
var MAX_PIXELS = 4096 * 4096;
用它的地方在 openImage 的回调里:
function openImage() {
ipcRenderer.invoke('dialog:openImage').then(function(r) {
if (!r || r.canceled || !r.filePaths || r.filePaths.length === 0) return;
var filePath = r.filePaths[0];
ipcRenderer.invoke('fs:readImage', filePath).then(function(rr) {
if (!rr.success) { setStatus(rr.error); return; }
var img = new Image();
img.onload = function() {
var w = img.naturalWidth, h = img.naturalHeight;
if (w * h > MAX_PIXELS) {
setStatus('图像 ' + w + '×' + h + ' 超过 4096×4096 像素上限');
return;
}
注意判断顺序:先读文件(字节闸门在主进程),再解码成 Image(浏览器解码),最后在这里查像素数。也就是说,一张 20 MB 以内但解出来超过 16,777,216 像素的图,是会真的被解码一次再被拒的。
为什么不在读之前查?PNG 的 IHDR 与 JPEG 的 SOF 段里都有宽高,但要拿到它,得自己解析文件容器,还要处理 BMP 与各种变体。作者选了另一条路:让浏览器先解码成 Image,从 naturalWidth 与 naturalHeight 取尺寸。代价是超尺寸的大图会先完整解码一次,再被拒。
引擎里还有第三道,是给调用方准备的:
// ========== 校验 ==========
function validateImage(img, maxPixels) {
if (!img || !img.width || !img.height || !img.data) {
return { valid: false, error: '图像数据无效' };
}
var limit = maxPixels || 16777216; // 默认 4096x4096
if (img.width * img.height > limit) {
return { valid: false, error: '图像超过 ' + Math.round(limit / 1000000) + ' MP 上限' };
}
if (img.data.length !== img.width * img.height * 4) {
return { valid: false, error: '数据长度与尺寸不符' };
}
return { valid: true };
}
16777216 就是 4096×4096。同一个数在 renderer 和 engine 里各写了一次,一个用乘法表达式,一个用字面量。这是重复定义,但两处语义不同:renderer 那个是用户可见的上限,engine 这个是库函数的默认值,可以被调用方覆盖。
三道闸门,两个数字:20 MB 管文件,16,777,216 像素管内存。前者防的是磁盘上的大文件,后者防的是解码后的内存峰值——一张 4096×4096 的图,单份像素缓冲就是 64 MB,再算上处理链里 clone 出的中间副本,峰值还要往上走。
十、CSS 第 2 行写着禁项,第 478 行破了一次
这个工程的样式表开头两行是这样的:
/* 星图遥感 Catppuccin Mocha 深色主题 */
/* 约束:不使用绝对/固定定位、层级叠放、形变与动画(鸿蒙禁项) */
这两行约束读起来像是整份文件都不许用固定的六个属性。但把 548 行里的六个统计项数一遍,并不是全 0:
- position: fixed:1 处
- position: absolute:0 处
- z-index:1 处
- transform:0 处
- animation:0 处
- transition:0 处
两处都在同一个块里,第 476 到 485 行:
/* ========== 关于对话框(Electron 壳内 fixed 覆盖) ========== */
#aboutDialogLayer {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(17, 17, 27, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
头注释说的是"不使用",这里是"用了一次",但它自己在注释里给了理由:Electron 壳内。
这句话值得展开。那两行约束的语境是 ArkUI:在原生页面的声明式布局里,固定定位与层级叠放属于另一套心智模型,模板作者把它写成红线,是为了让壳层不要往那个方向长。可这个应用的业务界面根本不跑在 ArkUI 上,它跑在 Electron 的 Chromium 渲染进程里,index.html 是普通网页,fixed 与 z-index 就是 CSS 标准行为,不经过 ArkUI 的布局体系。
所以这不是"破例",是"分层"。壳层(ArkTS)遵守禁项,壳内的网页(CSS)不受约束。头注释写得比较绝对,第 476 行的注释把例外条件补上了。
真正需要盯的是壳层。壳里的 18 个 ets 文件,搜 position 与 .position( 都是 0 次,它们只管窗口、Ability、Web 容器,不碰布局定位。禁项约束的是那一层,而那一层确实干净。
不过我还是建议下一版把头注释改精确一点,比如"壳层 ArkUI 不使用绝对/固定定位、层级叠放、形变与动画"。现在这两行摆在文件最前面,任何人先读注释再读代码,都会先愣一下。
body 本身是标准文档流布局,三行一列:
body {
font-family: var(--font-ui);
font-size: 13px;
background: var(--base);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
}
flex column,工具栏、主体、状态栏三块,靠 flex-shrink: 0 把上下两条固定住,中间那块撑满剩余高度。没有一行 grid-area,没有一行 calc 定位。
十一、三份翻译表:12 / 8 / 8
资源目录下四份 string.json,键数不一样:
| 文件 | 键数 | 字节 |
|---|---|---|
| AppScope/base | 1 | 90 |
| electron/base | 12 | 988 |
| electron/en_US | 8 | 658 |
| electron/zh_CN | 8 | 644 |
AppScope 那份只有 app_name,作用域是整个应用,值"星图遥感"。
electron/base 12 个键,en_US 与 zh_CN 各 8 个。差的是这四个:
- BrowserAbility_desc
- BrowserAbility_label
- StatelessAbility_desc
- StatelessAbility_label
这四个键只在 base 里有。按鸿蒙的资源回退规则,en_US 与 zh_CN 环境下找不到对应键,会回退到 base。base 里 BrowserAbility_label 的值是 BrowserAbility,StatelessAbility_label 的值是 StatelessAbility。所以英文和中文系统下,这两个 Ability 的名字都显示成英文单词。
这两个 Ability 是不是用户可见的?BrowserAbility 的 exported 是 false,StatelessAbility 的 exported 是 true 但 process 是 :browser,launchType 是 specified,是给浏览器内核子进程用的,不进桌面。所以"名字显示成英文"这件事,在正常使用路径上看不到。
更有意思的是两处反向:
第一处,en_US 的 EntryAbility_label 值是"星图遥感"。英文资源包里放了一个中文值。这不是漏翻译,更像是"应用名不翻译"的策略——中文品牌名在英文环境里也保持中文。
第二处,zh_CN 的 EntryAbility_desc 值是"description"。中文资源包里放了一个英文值。
逐键对照:
| 键 | base | en_US | zh_CN |
|---|---|---|---|
| module_desc | module description | module description | 模块描述 |
| EntryAbility_desc | description | description | description |
| EntryAbility_label | 星图遥感 | 星图遥感 | 星图遥感 |
| button_ok | ok | ok | 确认 |
| button_cancel | cancel | cancel | 取消 |
| StatusBarEntryAbility_desc | Application status bar | Application status bar | 应用状态栏 |
| StatusBarEntryAbility_label | Application status bar | Application status bar | 应用状态栏 |
| OpenNewWindow_label | OpenNewWindow | OpenNewWindow | 打开新窗口 |
规律很清楚:zh_CN 翻译了 module_desc、button_ok、button_cancel、StatusBar 两条、OpenNewWindow,共 6 项;没翻的是 EntryAbility_desc(description)和 EntryAbility_label(本来就不翻)。
这两个字段会进模块的 description。前者中文环境是"模块描述",后者是"description"。若某个界面直接渲染 description,中文环境下就会露出这个英文词。这一段我没有在真机上逐屏确认,只记录资源值本身。
三份表的键集合不齐、值有反向残留,这是多语言资源最常见的状态:主语言补齐了,次要语言跟着补了一半。要修的话是一分钟的事——把 en/zh 各补上那四个 Ability 键,把 zh_CN 的 EntryAbility_desc 改成中文。这篇不代改,只把它记进待办。
十二、14 条权限的来历,以及第 15 条为什么被注释
这个工程的 web_engine 模块声明了 14 条权限,另外定义了 1 条:
- INTERNET
- GET_NETWORK_INFO
- ACCESS_CERT_MANAGER
- RUNNING_LOCK
- 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。
14 加 1 这个数字组合,一直是壳工程里最容易说不清的一处。这个工程的文件里留着答案,就在第 49 到 61 行:
{
"name": "ohos.permission.LOCK_WINDOW_CURSOR"
}
// WEB_NATIVE_MESSAGING 是系统权限,不可在 requestPermissions 中自定义声明
// {
// "name": "ohos.permission.WEB_NATIVE_MESSAGING"
// }
],
"definePermissions": [
{
"name": "ohos.permission.LOCK_WINDOW_CURSOR"
},
]
三条注释,把来历讲清楚了:这里本来是 15 条申请,其中 WEB_NATIVE_MESSAGING 是系统权限,模板作者试过,发现不能在 requestPermissions 里自定义声明,于是注释掉,只留文字说明。
所以 14 不是设计出来的数,是 15 减 1。而那个 1(LOCK_WINDOW_CURSOR)又被搬进 definePermissions,成了自定义权限——因为它不是系统预置权限,得自己定义再申请。
把这 14 条与业务功能逐条对一遍,就会发现它们不是按功能裁的:
- 数量 14 条,一条不多一条不少,正好是模板给的最大集;
- definePermissions 里那条也是 LOCK_WINDOW_CURSOR,与申请项同名;
- 顺序从 INTERNET 一直排到 LOCK_WINDOW_CURSOR,没有按使用频率或功能分组。
这个应用要不要 PRINT?不要。要不要 GYROSCOPE、ACCELEROMETER?不要——一个遥感图像处理器,加速度计和陀螺仪没有任何用途。要不要 ACCESS_BIOMETRIC?不要。
但业务代码里一行都不碰这些 API:
- index.html:fetch 0、XMLHttpRequest 0、WebSocket 0、localStorage 0
- renderer.js:同样全 0
- otb-engine.js:同样全 0
- otb.css:同样全 0
- main.js:只有 1 处 require(‘fs’),没有网络
一个纯本地的图像处理器,声明了 INTERNET、GET_NETWORK_INFO、ACCESS_CERT_MANAGER、ACCESS_BIOMETRIC、PRIVACY_WINDOW、WINDOW_TOPMOST、GYROSCOPE、ACCELEROMETER、PRINT。
这 14 条不是这一篇能裁的。理由很实在:壳层的 ArkTS 里有网络与账号相关代码——18 个 ets 文件里,NetworkKit 出现 1 次,AccountKit 出现 2 次——把这些权限删掉,壳可能编译通过但运行时崩在某个不走的主路径上。删权限必须配合删壳代码,是另一个工程量的活。
要做到什么程度才算干净?我给自己定的判据是三条:
- 业务代码里 0 处调用某权限对应的 API;
- 壳层代码里也 0 处调用;
- 删掉该权限后,真机冷启动、打开文件、导出文件三条路径都正常。
三条都满足才删。这一篇只完成了第 1 条,所以只记录,不动手。
【配图位 5:module.json5 第 49-61 行的编辑器截图,要把三条注释、LOCK_WINDOW_CURSOR 的申请与 definePermissions 里的同一条一起拍进去。这张图是"14 从 15 减 1 而来"的唯一证据。】
十三、包体解剖:28 个条目,一个 167 MB 的 .so
现在回到最开始那组数字,一个条目一个条目地看。
提审物 ohos_hap-default-signed.app,87,009,906 字节,2026-10-14 23:27:44。外层解开只有 3 个条目:
| 条目 | 原始 | 压缩后 |
|---|---|---|
| pac.json | 665 | 665 |
| pack.info | 1,090 | 1,090 |
| electron-default.hap | 193,930,499 | 86,993,159 |
hap 从 193.9 MB 压到 86.99 MB,压掉 55.1%。这是 .app 这一层干的活。
hap 里面 28 个条目,解压合计 193,926,507 字节。前 15 名:
| 条目 | 字节 | 占解压总量 |
|---|---|---|
| libs/arm64-v8a/libelectron.so | 167,471,912 | 86.36% |
| resources/resfile/icudtl.dat | 10,464,144 | 5.40% |
| resources/resfile/resources.pak | 5,777,343 | 2.98% |
| libs/arm64-v8a/libadapter.so | 2,621,200 | 1.35% |
| libs/arm64-v8a/libffmpeg.so | 2,181,528 | 1.12% |
| libs/arm64-v8a/libc++_shared.so | 1,334,688 | 0.69% |
| ets/modules.abc | 915,812 | 0.47% |
| resources/base/media/product_logo_32.png | 590,480 | 0.30% |
| resources/base/media/startIcon.png | 590,480 | 0.30% |
| resources/resfile/locales/zh-CN.pak | 498,227 | 0.26% |
| resources/resfile/locales/en-US.pak | 492,210 | 0.25% |
| resources/base/media/app_icon.png | 227,269 | 0.12% |
| resources/resfile/chrome_200_percent.pak | 221,300 | 0.11% |
| resources/resfile/v8_context_snapshot.bin | 214,155 | 0.11% |
| resources/resfile/chrome_100_percent.pak | 147,793 | 0.08% |
按目录汇总:
- libs:173,609,328,89.53%
- resources:19,387,624,10.00%
- ets:915,812,0.47%
- 其余(module.json、pack.info 等):13,743,0.007%
业务代码六个文件在这 28 个条目里的哪一格?resources/resfile/resources/app/ 下面,合计 78,285 字节,占 0.040%。
现在能回答第一个问题了:87 MB 的包,重量不在业务,在运行时。
libelectron.so 一个文件 167.5 MB,是 Electron 的 Chromium 内核加 Node.js 运行时,aarch64 架构。icudtl.dat 是 ICU 国际化数据,10.5 MB,Chromium 的文本处理离不开它。resources.pak 加两个 locales pak 加两个 chrome 百分比 pak,是 Chromium 的界面资源与本地化包,合计约 7.1 MB。libffmpeg.so 2.2 MB,媒体解码。libadapter.so 2.6 MB,这是鸿蒙侧给 Electron 做的适配层,openharmony 上的 Electron 靠它把 Chromium 的窗口与图形接口对到原生。
有一个细节必须点出来:28 个条目在 hap 里全部是 store 模式,compress_type 都是 0,原始大小与压缩后大小完全相同。所以 hap 文件本身是 193.9 MB 不假,但它是个"没压缩的 zip"。真正压缩发生在 .app 那一层,一次 deflate 把 193.9 MB 压到 87 MB。
这个顺序值得记住:判断包大小要看 .app,不能看 hap。hap 193.9 MB 的数字会吓人,提审物是 87 MB。
另一处对照:符号包目录下也有一份 libelectron.so,167,471,912 字节,和包内完全相同。而 libc++_shared.so 在两个地方不一样:包内 1,334,688,符号目录里 1,825,624,差的 490,936 字节在包里不存在,只在符号产物里。
还有图标。AppScope 下三个 png:
- app_icon.png 590,480
- product_logo_32.png 590,480
- startIcon.png 590,480
三个文件 SHA256 相同,622137893f50154c 开头,就是同一张图换了三个名字。
包内只保留了两张 590,480(product_logo_32、startIcon),app_icon 变成了 227,269,SHA256 是 735a5f70e639795e 开头,与磁盘上的不同。
也就是说,构建工具在打包时对 app_icon 做了一次重编码或缩放。这是工具链行为,工程里没有对应代码,我只能记录现象,不推测算法。要复现的话,拿磁盘那张 590,480 和包内那张 227,269 各解一次 PNG 头,比对宽高就能知道是缩放了还是只重压了。
三张同图在磁盘上共 1,771,440 字节,进包后是 1,408,229 字节(590,480 × 2 + 227,269)。相比 167 MB 的运行时,这是零头。但它是唯一一张用户能看见的图,值得单独记一行。
十四、壳与字节码:1,355 行的壳,915,812 字节的 abc
开头说过壳与业务是两条链路,这一节把两条链路的证据各自摆出来。
先看壳。这个工程的 electron 模块有 18 个 ets 文件,合计 47,055 字节、1,355 行。最大的是 pages/QuickLoginButtonComponent.ets,25,241 字节、638 行——一个文件占了整个壳的一半还多。
18 个文件逐个算 SHA256:
| 文件 | 字节 | 哈希 |
|---|---|---|
| Application/AbilityStage.ets | 329 | 170c7aa2edc5b92b |
| entryability/BrowserAbility.ets | 1,218 | 31c9b7bfc420502e |
| entryability/EntryAbility.ets | 1,142 | fbd07a01be067d01 |
| entryability/StatelessAbility.ets | 1,303 | 7f8073398b7e51a1 |
| entryability/StatusBarEntryAbility.ets | 1,161 | 2838dc6627a38e2a |
| entryability/TaskManagerAbility.ets | 1,206 | afe5a9a9f2cd2da6 |
| extensionAbility/BrowserEmbeddedAbility.ets | 863 | 7d93bcd68de338f3 |
| pages/EmbeddedWindow.ets | 653 | 8a7683a862d6b5a9 |
| pages/Index.ets | 2,607 | 49a9e2478a1a9761 |
| pages/Login.ets | 881 | 9abc754da51032d9 |
| pages/NodeHandleSubWindow.ets | 1,839 | 2e5188c585818b6b |
| pages/NodeHandleWindow.ets | 4,061 | ed7f5d27a8b7a5c9 |
| pages/QuickLoginButtonComponent.ets | 25,241 | 819636bb1c489e50 |
| pages/StatusBarPage.ets | 307 | 45cdde116314cbbb |
| pages/SubWindow.ets | 406 | 36269af5badcc24c |
| pages/WebPage.ets | 2,785 | 3d8c9996f6036365 |
| pages/WindowNode.ets | 641 | 33aad1fef985ae0d |
| process/CustomChildProcess.ets | 412 | 09517e85fcc08412 |
这 18 个文件里,搜 position 与 .position( 都是 0 次——壳只管窗口、Ability、Web 容器,不碰布局定位,这一点第十节已经量过。1,355 行 ArkTS 干的事就是搭一个能装 Chromium 的容器。
再看字节码。ets/modules.abc 是 915,812 字节,它是这 18 个文件的编译产物。判断"业务有没有进字节码",只要看业务代码在包里的位置:78,285 字节全在 resources/resfile/resources/app/ 下,六份文件以资源形式存放,不参与 ArkTS 编译。所以这 915,812 字节里没有一行 JS 业务逻辑,装的是 Ability 注册、窗口创建、Web 容器初始化,以及与页面之间的少量桥接。
两条链路各有一个可测的数字,而且互不替代:
- 想知道业务有没有进包,看 resfile 下那六份文件的字节与哈希;
- 想知道壳有没有变,看 ets/modules.abc 的字节与哈希;
- 两个数字都变了,才是"业务和壳一起改过";只变一个,就只改了一边。
这条判据可以当场验:改一行 renderer.js 重新打包,resfile 里那份文件的哈希会变,abc 的字节数不动;改一行 EntryAbility.ets,反过来——abc 变,业务文件不动。两个数字各管一边,这就是"业务不进字节码"的可操作版本。
十五、还没做的事
按惯例,把这一篇没验证的东西列清楚,免得读者把推断当结论。
- 权限拒绝态没有实测。 我没有在真机上逐条拒绝这 14 条权限,也没有验证拒绝之后哪条路径会断。文中只说"业务代码 0 处调用",没说"删掉就没事"。
- 没有用 hap-sign-tool 验签。 签名配置读的是 build-profile.json5,显示 keyAlias 仍是 debugKey,证书三件套仍指向 .ohos/config 下的默认调试证书,两个密文各 86 字符、以 000000 开头。这只说明配置文本是这个样子,不代表包内签名链的实际情况。要确认得用官方工具跑一次 verify。
- 没有反编译 abc。 文中所有关于"字节码里装了什么"的判断,都来自包内文件尺寸与资源位置,没有把 abc 还原成 ArkTS 源码。915,812 字节里具体有哪些类,除了能从资源键名推断的部分,其余没查。
- 没有实测 base64 的内存峰值。 33% 是编码膨胀率,不是进程内存增量。真实峰值还要加上字符串拷贝、IPC 序列化、Image 解码三份副本,这几项没测。
- 没有测处理链耗时。 界面上的 ms 是 Date.now() 差值,不是性能分析数据。一张 4096×4096 的图跑 CLAHE 到底几秒,取决于设备,文中没给数字。
- 图标重编码的原因未确认。 磁盘 590,480 与包内 227,269 的差异是事实,是缩放还是重压,需要解 PNG 头比对宽高,这一步没做。
这六条里,第 1、2 条是下一轮最该补的。签名还停在 debugKey 这件事,只要 build-profile.json5 不变,下一次构建仍会用调试证书——写完代码忘了改签名配置,是这类壳工程最常见的收尾漏洞。
十六、写在最后
写完这一篇,回头看整条线,这个应用量的其实只有一件事:一个桌面应用搬到鸿蒙 PC 上,究竟要经过哪些关口。把这一篇里的判据抽出来,是四条:
第一条,产物三分法。构建模式看 .pages.info 与 sourceMaps 有没有进包,权限看 module.json,签名看 build-profile 与证书。三件事分开判,不能互相代替。混淆这三者,就会出现"打了 release 包,权限还是模板那 14 条,签名还是 debugKey"这种半成品。
第二条,业务不进字节码。这是 Electron 壳应用在鸿蒙上的架构事实:业务 JS 放在 resources/resfile 里当资源,ArkTS 壳编译成 modules.abc。所以字节码大小只反映壳,业务改多少都不影响它。想知道业务有没有进包,要查 resfile,不要查 abc。
第三条,权限表是模板级的。这个工程 14 条申请加 1 条定义,一条不差。14 不是按功能裁出来的,是 15 减 1——第 15 条 WEB_NATIVE_MESSAGING 是系统权限,不能自定义声明,被注释掉了。要真正精简权限,得连壳代码一起删,不是改一个数组。
第四条,包体的大头永远是运行时。这个工程 87 MB 里 86.36% 是一个 .so。业务代码 78 KB,改动它对包体几乎没有影响。想让包变小,唯一的路是换一个更小的运行时或者做裁剪。
这四条不是从文档里抄的,是从这一个包的 28 个条目、解压后的字节数、以及一份 module.json 里一条一条数出来的。每一条都能复现:把 .app 解开,量三个数——libelectron.so 的字节、modules.abc 的字节、resfile 里业务的字节——三条结论就都在里面了。
至于这个应用本身,它是一件完成度不错的小工具:18 个真实像素算子、可串链、带回滚失败、带直方图、带探针、带缩放、带导出。业务代码 78 KB,没有一行网络请求,所有运算在本地完成。它的问题不在功能,在几处没对齐的文案和一套没用上的权限——这些是上架前的收尾活,不是架构问题。
这篇到此为止。如果这些数字对你有用,拿去做你自己的体检表;如果不认同某一条判据,把你的包解开量一遍,数字会说话。
更多推荐



所有评论(0)