上个月给一个工业设备 App 做 OTA 升级模块,固件包 180MB 左右,下载完要算一遍 MD5 跟服务端比对,确认没被传坏再交给原生升级。功能本身不复杂,我一开始也没当回事——JS 里算哈希的库一抓一把,spark-md5 加个 FileReader 分片读取,半小时就能写完。
写完就翻车了。
在红米 Note 11 上跑,进度条走到 100% 之后页面直接僵住,滑动没反应,下拉没反应,连自己写的 loading 动画都不转了。掐表一算,11.4 秒。测试小哥连续点了三次按钮,App 崩了。
这篇文章就是那次翻车的完整复盘,以及后来改用 UTS 写原生插件的落地过程。如果你也在 uni-app 里跟大文件、加解密、图像处理这类重活较劲,这篇应该能省你两天时间。
先别急着换方案,量一下瓶颈在哪
我最初以为是文件读取慢,后来拿 Performance 面板一看,事情没那么简单。
spark-md5 在 JS 层的做法大致是:把文件切成若干 Blob,用 FileReader 读成 ArrayBuffer,转成 Uint8Array,再喂给 MD5 算法做增量 update。问题出在三个地方:
- 单线程。MD5 的每一轮都要做位运算和模加,180MB 数据量意味着上亿次运算,全部跑在渲染线程上。主线程被占满,UI 自然就不动了。
- 内存翻倍。分片读取虽然避免了整包加载,但每次 update 都要把数据在 ArrayBuffer、Uint8Array、内部状态数组之间倒腾一遍,峰值内存比文件本身还大。
- JS 层面的数字精度。MD5 内部大量使用 32 位无符号整数运算,JS 只有 double,每次都要用位运算符做隐式截断,这个开销在循环里被放大了几千万倍。
结论很清楚:不是库的问题,是这条路本身就不该用 JS 走。这类计算密集 + IO 密集的活,交给平台原生的摘要实现,本来就是它的本职工作。
为什么不用现成的原生插件
有人会说,插件市场不是有现成的哈希插件吗,直接装上不就行了。
我确实试了两个。问题有两个:一是这类插件通常基于历史 JNI 方案打包,体积不小,而我们这个 App 已经装了好几个插件,包体快到渠道限制了;二是插件市场的东西没法改,我要的那个「分段回调进度」的能力,两个插件都没有,只能拿最终结果。
还有个更现实的原因:这个项目要发两个端,Android 走的是工控平板,iOS 走的是质检 PAD,两端行为必须一致。插件市场里能同时覆盖两端、还持续维护的,不多。
所以最后选了 UTS。写起来是 TypeScript 的样子,编译后分别生成 Kotlin 和 Swift,逻辑一次写、两端各编一份,改起来也不依赖第三方。
把插件的骨架搭起来
在 HBuilderX 里右键项目里的 uni_modules 目录,新建 uni_modules 插件,类型选 UTS 插件。生成出来的目录差不多是这样:
uni_modules/
└── my-hash/
├── package.json
├── readme.md
└── utssdk/
├── interface.uts
├── index.uts
├── app-android/
│ └── index.uts
├── app-ios/
│ └── index.uts
└── web/
└── index.uts
几个目录的分工要清楚,不然改半天不知道改哪个:
interface.uts:只放类型定义,不写实现。它会被所有平台共享,是调用方看到的「契约」。index.uts:默认入口。如果没有平台专属目录,就走这里。可以用来做兜底或者转发。app-android/index.uts:编译成 Kotlin,跑在 Android 上。app-ios/index.uts:编译成 Swift,跑在 iOS 上。web/index.uts:H5 端的降级实现,没有它的话 H5 编译会报找不到符号。
package.json 里 HBuilderX 会自动填好大部分字段,你真正要动的是 platforms 节点,明确声明这个插件支持哪些端。如果你只打算在 App 里用,把 web 和各家小程序都关掉,能省掉不少编译时的困惑。
接口先定死,后面少吵架
interface.uts 我建议花十分钟认真写。类型定歪了,后面两端实现各写各的,调用方就得写一堆 if-else 去适配。
// utssdk/interface.uts
export type HashAlgorithm = 'MD5' | 'SHA-1' | 'SHA-256'
export type HashOptions = {
/** 本地文件绝对路径,App 端就是 plus.io 那套路径 */
filePath: string
/** 默认 MD5 */
algorithm?: HashAlgorithm
/** 每次读取的字节数,默认 512KB */
chunkSize?: number
/** 每处理完一块回调一次,参数是已处理字节数 */
onProgress?: (processed: number) => void
}
export type HashResult = {
algorithm: HashAlgorithm
/** 小写十六进制 */
hex: string
/** 参与计算的总字节数 */
bytes: number
/** 耗时,毫秒,方便做性能对比 */
costMs: number
}
export type HashFailure = {
code: number
message: string
}
export interface MyHashApi {
hashFile(options: HashOptions): Promise<HashResult>
}
几个约定值得说明。算法名我特意写成 'SHA-1' 带横线的形式,是因为 Android 的 MessageDigest.getInstance() 就认这个写法,而 JS 侧习惯写 SHA1。定成前者,两端实现都少做一次映射。
onProgress 用回调而不是事件,是因为 UTS 里跨端的事件机制在不同版本上表现有差异,回调最稳。后面在页面里用闭包捕获一下,一样能更新进度条。
costMs 塞进返回值,纯粹是我自己的习惯——性能优化这东西,不埋点就永远说不清有没有变快。
Android 端的实现
Android 这边其实没什么花活,标准库里的 MessageDigest 已经足够快,我们要做的只是别把数据拽到 Java 堆上瞎折腾。
// utssdk/app-android/index.uts
import MessageDigest from 'java.security.MessageDigest'
import FileInputStream from 'java.io.FileInputStream'
import BufferedInputStream from 'java.io.BufferedInputStream'
import { HashOptions, HashResult, HashFailure } from '../interface.uts'
export function hashFile(options: HashOptions): Promise<HashResult> {
return new Promise<HashResult>((resolve, reject) => {
const algorithm = options.algorithm ?? 'MD5'
const chunkSize = options.chunkSize ?? 512 * 1024
const startedAt = Date.now()
let digest: MessageDigest
try {
digest = MessageDigest.getInstance(algorithm)
} catch (e) {
reject({ code: 9101, message: '不支持的算法: ' + algorithm } as HashFailure)
return
}
let input: FileInputStream | null = null
let buffered: BufferedInputStream | null = null
try {
input = new FileInputStream(options.filePath)
buffered = new BufferedInputStream(input, chunkSize)
const buffer = new Uint8Array(chunkSize)
let processed = 0
let readCount = 0
while (true) {
readCount = buffered.read(buffer)
if (readCount == -1) {
break
}
digest.update(buffer, 0, readCount)
processed += readCount
if (options.onProgress != null) {
options.onProgress(processed)
}
}
const raw = digest.digest()
resolve({
algorithm: algorithm,
hex: toHex(raw),
bytes: processed,
costMs: Date.now() - startedAt
} as HashResult)
} catch (e) {
reject({ code: 9102, message: e.toString() } as HashFailure)
} finally {
// 无论成功失败都要关流,别指望 GC
if (buffered != null) {
try { buffered.close() } catch (ignored) {}
}
if (input != null) {
try { input.close() } catch (ignored) {}
}
}
})
}
function toHex(bytes: Uint8Array): string {
let out = ''
for (let i = 0; i < bytes.length; i++) {
const v = bytes[i] & 0xFF
out += (v < 16 ? '0' : '') + v.toString(16)
}
return out
}
有三处细节是踩过坑之后才补上的。
第一,try/finally 关流不能省。我第一版没写 finally,测试时特地造了一个「读到一半权限被回收」的场景,流没关,连续触发十几次之后直接 Too many open files。Android 上每个进程能持有的 fd 是有上限的,别赌。
第二,digest.update(buffer, 0, readCount) 里的 offset 和 length 必须显式传。我之前偷懒只写 digest.update(buffer),结果最后一次读取不足 512KB 时,Buffer 的尾部残留了上一轮的旧数据,算出来的哈希跟服务端对不上。这个 bug 特别阴,因为文件大小是 512KB 整数倍时完全看不出来。
第三,返回的字节数组要手动转十六进制。UTS 里没有 Buffer.from(...).toString('hex') 这种便利方法,得自己循环。别用字符串拼接去做 toString(16) 之外的优化,这个循环只在最后跑一次,不构成瓶颈。
iOS 端的关键差异
iOS 这边思路一样,工具不一样。CryptoKit 没有 MessageDigest 那种「一个对象走天下」的抽象,MD5 和 SHA 系列分属不同的类型,得分开处理。
// utssdk/app-ios/index.uts
import { FileHandle } from 'Foundation'
import { SHA256, Insecure } from 'CryptoKit'
import { HashOptions, HashResult, HashFailure } from '../interface.uts'
export function hashFile(options: HashOptions): Promise<HashResult> {
return new Promise<HashResult>((resolve, reject) => {
const algorithm = options.algorithm ?? 'MD5'
const chunkSize = options.chunkSize ?? 512 * 1024
const startedAt = Date.now()
let hasher: any
if (algorithm == 'SHA-256') {
hasher = SHA256()
} else if (algorithm == 'MD5') {
hasher = Insecure.MD5()
} else {
reject({ code: 9101, message: 'iOS 端暂不支持: ' + algorithm } as HashFailure)
return
}
let handle: FileHandle | null = null
try {
handle = FileHandle.fileHandleForReadingAtPath(options.filePath)
if (handle == null) {
reject({ code: 9103, message: '打不开文件: ' + options.filePath } as HashFailure)
return
}
let processed = 0
while (true) {
const chunk = handle.readDataOfLength(chunkSize)
if (chunk == null || chunk.length == 0) {
break
}
hasher.update(data = chunk)
processed += chunk.length
if (options.onProgress != null) {
options.onProgress(processed)
}
}
const raw = hasher.finalize()
resolve({
algorithm: algorithm,
hex: toHex(raw),
bytes: processed,
costMs: Date.now() - startedAt
} as HashResult)
} catch (e) {
reject({ code: 9102, message: e.toString() } as HashFailure)
} finally {
if (handle != null) {
try { handle.closeFile() } catch (ignored) {}
}
}
})
}
iOS 那边有两个必须记住的点。
一是 CryptoKit 的 SHA256 其实是有增量接口的,构造一个 hasher,反复 update,最后 finalize。网上不少文章直接写 SHA256.hash(data:) 一次性算完,小文件没问题,我们这 180MB 一上去内存直接飙到 400 多兆,被系统 kill 过两次。
二是 Insecure.MD5 这个名字不是笔误。CryptoKit 把 MD5 和 SHA-1 归到 Insecure 命名空间下,用的时候要显式写出来。这个设计挺有意思的,算是用类型系统提醒你「你确定要用这个吗」。
另外注意我上面 handle.readDataOfLength 返回的是 NSData,直接丢给 update(data:) 就行,不需要像 Android 那样关心 offset——因为每次都新建了一个对象,不存在复用残留的问题。代价是多了一次内存分配,测下来差异在毫秒级,可以接受。
在页面里怎么调
插件写完之后,调用方看起来跟调一个普通 Promise 方法没区别:
<template>
<view>
<button @click="verify">校验固件</button>
<text>进度:{{ percent }}%</text>
<text>结果:{{ resultText }}</text>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { hashFile } from '@/uni_modules/my-hash'
const percent = ref(0)
const resultText = ref('')
async function verify() {
const totalBytes = 180 * 1024 * 1024
try {
const res = await hashFile({
filePath: '_doc/update/firmware.bin',
algorithm: 'MD5',
chunkSize: 512 * 1024,
onProgress: (processed) => {
percent.value = Math.floor(processed / totalBytes * 100)
}
})
resultText.value = '校验完成,耗时 ' + res.costMs + 'ms,MD5 = ' + res.hex
if (res.hex !== remoteMd5) {
uni.showToast({ title: '固件校验失败', icon: 'none' })
}
} catch (err) {
resultText.value = '失败:' + JSON.stringify(err)
}
}
</script>
这里有个小细节值得提。进度回调是原生层直接调回 JS 的,频率取决于 chunkSize——512KB 一块,180MB 就是 360 次回调,不算密。但如果把 chunkSize 调到 4KB,回调会变成四万多次,跨语言调用的开销就压不住了,界面反而更卡。我的经验是 chunkSize 不要低于 256KB,也不要大于 4MB。
实测数据
同一台红米 Note 11,同一个 180MB 固件包,同一条 USB 传输路径:
| 方案 | 耗时 | 期间界面是否可交互 | 峰值内存 |
|---|---|---|---|
| spark-md5 分片(2MB) | 11.4 秒 | 完全冻结 | 约 340MB |
| spark-md5 + Worker | 13.2 秒 | 可交互,但明显掉帧 | 约 410MB |
| UTS 原生插件(512KB) | 0.28 秒 | 流畅 | 约 45MB |
将近 40 倍的差距。Worker 版本反而更慢,是因为跨线程传大数据要序列化,这个开销吃掉了一部分收益,而内存还更高。
iOS 端测下来是 0.31 秒,稍慢一点,原因是每块都新建 NSData。(这个差距我没去优化,够用了。)
踩坑清单
把过程中遇到的其他问题整理一下,省得你一个个撞。
类型必须显式标注。UTS 不是 TypeScript,它更接近强类型语言。函数返回值、对象字面量,能标就标,不然 Android 编译出来的 Kotlin 会因为类型推断失败而报一堆看不懂的错。我一开始写 const algorithm = options.algorithm ?? 'MD5',编译器把它推断成了 string,传给 getInstance() 时报类型不匹配,排查了好一会儿。
可选参数用 ?,但 null 判断还是要写。UTS 里 options.onProgress 声明成可选之后,直接调用会因为可能为 null 而编译失败,必须显式判空。这一点上它比 TS 严格得多,一开始不习惯,用久了反而觉得踏实。
平台专属 API 放在各自的目录里。不要在 index.uts 里 import Android 的类,H5 端编译时会直接找不到符号。要么分开写,要么用条件编译包起来。我倾向分开写,结构清楚,也不容易忘。
错误对象别直接 throw 字符串。UTS 里抛自定义对象是支持的,但一定要定义成有 code 和 message 的类型,不然在 Promise 的 catch 里拿到的可能是一个平台译之后的字符串,没法判断错误种类。
版本差异要留意。UTS 这块还在快速迭代,不同 HBuilderX 版本对某些语法的支持程度不一样。写完先在真机跑一遍,别只在模拟器上验证。模拟器上过不代表真机过,我吃过这个亏——模拟器上文件权限宽松,真机上读 _doc 之外的路由直接被拒。
收个尾
这次改造之后,我顺手把项目里另外两个也在 JS 层硬扛的功能换掉了:一个是图片压缩前的缩略图生成,一个是本地日志文件的正则脱敏。前者从 800 毫秒降到 60 毫秒,后者从 2.1 秒降到 90 毫秒。
我的判断标准很简单:一段代码如果跑的时候界面不动、内存曲线是一条陡峭的斜线、而且业务上跟操作系统关系密切,那它就大概率不该待在 JS 层。UTS 现在最省事的地方不在于性能有多夸张,而在于它让「换到原生层」这件事的成本降到了可以在一个下午里做完。
当然,UTS 也不是银弹。它解决不了算法本身的开销,也管不了文件系统慢你。它只是帮你把计算搬到了它本来该在的地方。剩下的,还得自己写。

