uni-app 大文件哈希卡顿怎么破:用 UTS 插件把校验丢给原生层

2026-10-04 0 926

上个月给一个工业设备 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 也不是银弹。它解决不了算法本身的开销,也管不了文件系统慢你。它只是帮你把计算搬到了它本来该在的地方。剩下的,还得自己写。

uni-app 大文件哈希卡顿怎么破:用 UTS 插件把校验丢给原生层
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uni-app 大文件哈希卡顿怎么破:用 UTS 插件把校验丢给原生层 https://www.taomawang.com/web/uniapp/2859.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务