拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

鸿蒙Flutter适配fast_blurhash:Rust FFI图片占位优化实践

鸿蒙Flutter适配fast_blurhash:Rust FFI图片占位优化实践

1. 为什么是 blurhash:先聊聊图片加载焦虑这件事

1.1 图片加载焦虑的根源

你有没有遇到过这种情况:列表页里一堆卡片,每张卡片顶部都有封面图,网络正常时一切还好,一旦网络慢或者图片接口出了问题,整个界面就像得了皮肤病一样,先是空白,然后一块一块地跳出来,每次图片加载完成,周围的文字和布局还会跟着抖一下。用户的感觉就是"这个 App 卡了",实际上主线程根本没卡,是图片异步加载完成后的 layout 变化带来的视觉跳动,在弱网场景下这种体验会被无限放大。

我最早注意到这个问题,是在做一款内容社区类的 Flutter 应用时。信息流里大量瀑布流卡片,每张卡片都有封面图,接口返回的是图片 URL,真正拿到图片字节流还要等网络往返。图片没到之前,卡片区域只能空着,或者放一个纯色占位。纯色占位最大的问题是没有信息量——用户看到一片灰块,不知道这块区域到底要显示什么,更不会产生阅读期待。而且纯色块和大图之间的明暗反差很刺眼,加载完成的一瞬间会有一个明显的"闪烁"感。

后来我接触到了 blurhash 这类基于占位图的技术方案。它核心思路挺简单:在服务端返回图片 URL 的同时,一并返回一段几十个字符的哈希字符串;客户端拿到字符串后,先在本地解码出一张模糊的小图作为占位,等到原图加载完成后再替换。由于占位图带有原图的大致色彩和明暗分布,视觉上过渡会很自然,用户的心理感受是"内容已经出现了,正在变清晰",这在业界叫渐进式图片体验。

1.2 blurhash 解决的是"等待"和"跳动"两个问题

blurhash 真正的价值,是把"等待"和"跳动"这两个问题一起处理掉。等待产生焦虑,跳动产生错乱。一个几十字节的字符串,解码出来的图片虽然模糊,但用户可以隐约看到封面的构图、色调、明暗,这种"有内容"的反馈非常关键,心理学上叫感知响应速度,用户觉得 App 更跟手。

而且 blurhash 的解码算法是纯数学运算,不依赖图片解码库,不需要 IO,不需要 GPU,纯 CPU 就能跑。这就意味着它非常适合放在 UI 线程以外的任意地方执行,也适合在极端弱网条件下,先给用户一个有色彩倾向的视觉反馈。

不过这里有个尴尬的地方:Flutter 生态里已有的 blurhash 实现,大部分是纯 Dart 写的解码器。Dart 做字符串处理和循环计算,性能其实不差,但在低端 Android 设备或鸿蒙的低预算设备上,解码一张 32x32 的图可能就得十几毫秒甚至更多。列表滚动的时候,如果一屏有十张图同时在解码,那帧率就有压力了。再加上部分实现还用了compute之类的 isolate 方案,isolate 的启动和数据拷贝在频繁使用时也有额外开销。

所以我把目光投向了 fast_blurhash——一个把解码核心放到 Rust 里、通过 FFI 暴露给 Flutter 的库。它天然适合做跨端移植,因为 Rust 编译出的动态库可以被 Android、iOS 调用,那鸿蒙呢?理论上只要鸿蒙能加载标准的动态库,这条路就能走通。这篇内容就是我把它完整适配到鸿蒙 All In One 工程的过程记录,包括从 Dart 到 Rust 的调用链路、动态库的编译姿势、踩过的坑,以及最终的优化效果。

2. fast_blurhash 的库结构拆解:为什么性能关键路径要交给 Rust FFI

2.1 fast_blurhash 在 Flutter 生态里的定位

fast_blurhash 本质上是对官方 blurhash 算法的一个高性能封装,GitHub 上发布的初衷就是给 Flutter 开发者提供一个比纯 Dart 实现更快的选择。它的结构分两层:

  • Dart 层:负责接收字符串和尺寸参数,通过dart:ffi调用底层动态库,对外暴露异步 API。
  • Rust 层:实际执行 blurhash 解码的核心逻辑,把字符串还原成一组像素数据,再交给 Dart 侧包装成ui.Image。

我选择它而不是自己重写编码算法,原因是 blurhash 的官方算法虽然是公开的,但边角情况很多:字符集校验、位数对齐、DC 分量和 AC 分量的解码顺序,稍有偏差就会得到完全不同的图像。用成熟实现能节省大量验证时间,而且 fast_blurhash 的 Rust 底层在算法上做了 SIMD 相关的优化,这是纯 Dart 很难做到的。

2.2 为什么选 Rust 而不是 C,也不是 Dart

先回答大家一定会问的问题:为什么不直接用 C 编译动态库?C 当然可以,但 fast_blurhash 的上游主项目正好是 Rust 生态的,而且 Rust 有几个很实在的优势:

第一,内存安全性。blurhash 解码需要按照 base83 字符集解析字符串,如果传入非法字符串,C 的实现容易出现越界读取或未定义行为。Rust 的切片检查和Option处理能直接返回错误而不是崩掉进程,这在 App 环境里是刚需,没人希望一个占位图功能导致整个应用 crash。

第二,构建链友好。Rust 通过cargo-ndk可以直接交叉编译出 Android 的armeabi-v7a、arm64-v8a、x86_64等目标平台的.so文件。鸿蒙侧类似,鸿蒙的 Native 开发支持加载标准libxxx.so,Rust 生成的动态库不带 C++ runtime 的额外依赖,直接用就行。相比之下 C 编译要处理 GCC/Clang 的版本差异和系统库的依赖关系,Rust 的静态链接策略更能保证产物干净。

第三,Dart FFI 天然适配。Dart 的 FFI 只要求暴露 C ABI 接口,Rust 通过#[no_mangle]和extern "C"就能导出,整个过程非常平滑。我在适配前先做了一轮原生验证:在一个空的 Flutter 工程里直接用dart:ffi调用 Rust 编译出的动态库,编译一个decode_to_rgba接口,返回像素缓冲区指针,跑下来确实稳定。

用 Rust FFI 的另一个原因在性能。blurhash 的解码过程里有大量二维离散余弦变换(DCT)相关的矩阵运算,Rust 编译器对这类循环代码的优化效果很激进,Release 模式下可以做到与手写 C 相当甚至更优的水平。在真机上测,解码一张 32x32 的占位图,耗时普遍在 1 毫秒以内,而纯 Dart 方案在冷启动时能跑到 8 到 15 毫秒,差距是数量级的。

3. 鸿蒙适配的技术主线:先摸清鸿蒙 Flutter 插件的运行模型

3.1 Flutter 插件工程在鸿蒙上的组织形态

做鸿蒙适配之前,我一直以为鸿蒙的 Flutter 支持还只是"能跑起来"的阶段,但真正动手才发现,OpenHarmony 生态下的 Flutter 插件框架已经相当成体系了。插件在鸿蒙侧的落地,本质上不是把 Android 的代码搬过来,而是要按鸿蒙的模块化工程结构组织。

一个典型的鸿蒙 Flutter 插件工程,路径上会有类似这样的布局:

fast_blurhash/ ├── android/ # Android 原生实现 ├── ios/ # iOS 原生实现 ├── ohos/ # 鸿蒙原生实现,这是适配工作的主战场 │ ├── build-profile.json5 │ ├── entry/ │ │ └── src/ │ │ └── main/ │ │ ├── ets/ │ │ ├── cpp/ │ │ └── module.json5 │ └── fast_blurhash/src/main/ │ ├── ets/ │ │ └── Index.ets │ └── cpp/ │ ├── CMakeLists.txt │ └── native.cpp ├── lib/ # Dart 层实现 └── pubspec.yaml

Dart 层调用的还是MethodChannel或EventChannel,鸿蒙侧用 ETS 实现对应的平台通道处理器,响应来自 Dart 的调用。对于 fast_blurhash 这种纯计算型插件,最简单的接入方式是走MethodChannel,把 blurhash 字符串传到鸿蒙侧,在 ETS 里调用底层的 Rust.so,拿到像素数组后通过通道返回 Dart。不过这样做有一个效率损耗:RGBA 像素数据要通过通道序列化,数据量大时可观。

所以我采用混合策略:字符串解析和初步校验放在 Dart 侧,实际解码调用直接通过dart:ffi进入 Rust,绕开平台通道。鸿蒙侧只负责在工程里打包正确的.so文件,让它能加载。这就把鸿蒙插件变成一个壳,真正干活的是动态库。

3.2 鸿蒙侧加载动态库的路径与符号链路

鸿蒙工程加载动态库,和 Linux 非常接近,核心用系统提供的dlopen/dlsym接口。官方推荐的 HDC 调试模式和应用安装后的路径不同,这造成了不少适配问题——你在 DevEco Studio 里跑得通,打包成 HAP 装到真机上就找不到 so,原因就是加载路径不对。

实际正确做法是:把编译产物libfast_blurhash.so放到entry/libs/arm64-v8a/目录下,同时在module.json5里声明abiType,让打包工具把 so 带进 HAP 包。然后在 ETS 侧用dlopen加载时,不要直接写相对路径,最好通过hiLog输出当前 so 所在目录再拼接绝对路径。我踩过最痛的一次,就是动态库明明在包里,加载路径却拼错了一层,导致所有的 FFI 调用都静默失败,没有 crash,没有日志,就像石头掉进水里。

要确认符号是否导出,可以在 DevEco Studio 的终端里用llvm-nm或者鸿蒙 NDK 自带的工具检查:

llvm-nm -D libfast_blurhash.so | grep decode_blurhash

如果输出里有T decode_blurhash,说明符号导出了。没有的话,就是 Rust 侧#[no_mangle]没生效,或者函数名被 mangle 了。

4. 从 Dart 到 Rust 的端到端调用实现

4.1 Dart 侧 FFI 绑定

fast_blurhash 在 Dart 侧的核心绑定代码并不复杂,但有几个细节直接关系到成败。我先定义 FFI 接口:

import 'dart:ffi'; import 'dart:typed_data'; import 'package:ffi/ffi.dart'; typedef DecodeBlurhashNative = Pointer<Uint8> Function( Pointer<Utf8> hash, Int32 width, Int32 height, Int32 channels, Pointer<Int32> outBufferSize, ); typedef DecodeBlurhashDart = Pointer<Uint8> Function( Pointer<Utf8> hash, int width, int height, int channels, Pointer<Int32> outBufferSize, ); final DynamicLibrary _nativeLib = _openNativeLibrary(); void _bindNativeLibrary() { _decodeBlurhash = _nativeLib.lookupFunction<DecodeBlurhashNative, DecodeBlurhashDart>( 'decode_blurhash', ); }

这里有几个关键点值得展开讲。

第一,函数签名必须和 Rust 侧完全一致,哪怕多一个参数都不行。Rust 侧导出的decode_blurhash接收的参数顺序是:hash 字符串指针、宽、高、通道数、输出缓冲区大小指针。Dart 侧声明顺序错位,调用时会读到垃圾值,甚至访问非法内存导致崩溃。

第二,字符串传递用Pointer<Utf8>,调用前要把 Dart 的String转成utf8编码的 C 字符串。这一步要用malloc分配内存,调用完必须释放,否则就是内存泄漏。我封装了一个统一的_withCString辅助函数,确保每个入口都会释放内存。

第三,返回的是动态库内部malloc的指针,Dart 侧拿到之后要拷贝出来,不能直接把它当成 Dart 内存用。正确流程是:调用malloc分配一块 Dart 侧缓冲区,把动态库返回的数据memcpy进来,然后free掉动态库返回的指针。否则每次解码就泄漏一份像素数据,长时间使用必崩无疑。

4.2 Rust 侧编译与产物的处理

Rust 侧编译是适配里最容易出问题的一环。fast_blurhash 的 Rust 底层是独立的 crate,我在项目的Cargo.toml里这样配置:

[lib] name = "fast_blurhash_ffi" crate-type = ["cdylib"] [dependencies] blurhash = "0.1"

crate-type一定要是cdylib,只有这种类型才会产出可被其他语言调用的动态库,默认的rlib不行。然后提供一个简单的 C ABI 导出函数:

use std::ffi::{CStr, CString}; use std::os::raw::{c_char, c_int}; #[no_mangle] pub extern "C" fn decode_blurhash( hash: *const c_char, width: c_int, height: c_int, channels: c_int, out_buffer_size: *mut c_int, ) -> *mut u8 { if hash.is_null() || out_buffer_size.is_null() { return std::ptr::null_mut(); } let hash_str = unsafe { CStr::from_ptr(hash).to_string_lossy().to_string() }; match blurhash::decode(&hash_str, width as u32, height as u32, channels as u32) { Ok(pixels) => { let mut buf = pixels.into_raw_vec(); let ptr = buf.as_mut_ptr(); std::mem::forget(buf); // 防止 Rust 侧自动释放 unsafe { *out_buffer_size = (width * height * channels) as i32 }; ptr } Err(_) => std::ptr::null_mut(), } }

这里std::mem::forget是必须的:如果不调用,Rust 的Vec在函数返回时会自动释放堆内存,Dart 侧拿到的就是悬空指针,读取时要么拿到垃圾数据,要么直接段错误。反过来,Dart 侧拿到指针后拷贝完,要负责释放这块内存,因为 Rust 侧已经放弃了所有权。

编译命令在 macOS 上交叉编译 Android 目标时用这类方式:

cargo install cargo-ndk cargo ndk -t arm64-v8a -o ./ohos/entry/libs build --release

鸿蒙侧虽然不能直接复用 Android 的.so,但因为我们用的是标准 C ABI,arm64-v8a 架构下的产物可以直接放到鸿蒙工程的libs/arm64-v8a下,实测可以加载。不过为了严谨,我建议在鸿蒙 SDK 提供的 NDK 环境下重编一次,确保链接的 libc 版本兼容。鸿蒙的 libc 和 Android 的 bionic 存在差异,虽然简单场景下互通没问题,但长期维护时最好各自构建。

4.3 在鸿蒙工程里正确加载 .so

这一步是整个适配里最"玄学"的部分。前面提到要用dlopen,但在 DevEco Studio 工程里,C++ 侧可以通过CMakeLists.txt直接链接动态库,运行时不用手动dlopen。我把libfast_blurhash.so放到entry/libs/arm64-v8a/,然后在 CMake 里链接:

add_library(entry SHARED native.cpp) target_link_libraries(entry ${CMAKE_SOURCE_DIR}/../../../libs/arm64-v8a/libfast_blurhash.so )

如果只是在 ETS 层调用,就需要在module.json5的abilities里配置libs路径,然后用系统接口加载。两种方式各有优劣,C++ 侧链接适合我还要在 native 层做额外处理的场景,ETS 侧加载适合纯粹的通道调用。

注意:无论哪种方式,都要保证.so在 HAP 包里是可见的。验证方法很简单,把 HAP 解压后检查libs/arm64-v8a/下是否有对应文件,没有就是 build-profile 配置漏了。

4.4 封装成易用的异步 API

FFI 调用本身是同步的,如果在 UI 线程直接跑解码,遇到 64x64 的大占位图或批量解码场景,仍然会造成掉帧。所以我在 Dart 层把 FFI 调用包了一层Isolate:

Future<Uint8List> decodeBlurhashAsync({ required String hash, required int width, required int height, }) async { return compute(_decodeInIsolate, { 'hash': hash, 'width': width, 'height': height, }); }

compute是 Flutter 提供的简易 isolate 方案,适合这种无状态计算任务。这里要注意,compute的参数和返回值都必须能跨 isolate 传递,自定义对象需要编码成可序列化形式,所以我直接把参数通过Map<String, dynamic>传递,把结果通过Uint8List返回。

5. 适配过程里踩过的坑

5.1 动态库找不到的坑:静默失败最致命

我先在模拟器上跑通,再上真机,结果真机直接黑屏。查了半天发现 FFI 调用返回的是nullptr,但没有任何异常抛出来。后来在 C++ 侧加了日志,才看到是dlopen返回空指针。

原因是 DevEco Studio 的模拟器架构是 x86_64,我编译的 so 只有 arm64-v8a,模拟器加载时找不到对应架构的动态库。这是个非常低级的错误,但也是新手最容易踩的。解决方式是,一开始就同时编译两种架构,或者在模拟器上调试时单独编译 x86_64 版本。鸿蒙的模拟器对 x86_64 支持得很成熟,但 Rust 交叉编译要额外加 target。

5.2 ABI 目录与多架构:一块芯片两个世界

鸿蒙设备现在主流是 arm64-v8a,但还有不少 x86_64 的模拟器和个别特殊设备。如果只发布 arm64-v8a 版本,兼容性测试绝对过不了。加上现在还有 32 位兼容的历史包袱,适配时会把armeabi-v7a也带上。

建议在 CI 里配置三个目标:

rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -o ./ohos/entry/libs build --release

在鸿蒙工程那边,把三个目录的 so 都扔进对应的 ABI 文件夹。这个步骤在前期容易忽略,但后期一旦遗漏,用户设备一多就开始出现"某些设备上 App 崩溃"的反馈。

5.3 内存拷贝与 Dart GC 的风险

FFI 返回的指针如果直接转成TypedData且不拷贝,会有一个隐患:Dart 的 GC 不感知 native 内存,如果 Rust 侧手动释放了内存,而 Dart 侧还持有这个指针的Uint8List视图,就会读到已被回收的内存,产生随机崩溃和数据损坏。

我后来统一走"拷贝后释放"路线:

final Pointer<Uint8> nativePtr = _decodeBlurhash(...); try { final data = nativePtr.asTypedList(bufferSize); final copy = Uint8List.fromList(data); return copy; } finally { malloc.free(nativePtr); }

所有调用路径都走这个函数,不允许直接返回Uint8List.view。这个决定让我少排查了很多疑难杂症。

5.4 线程卡顿:EventChannel 不是救世主

最初我设想的是,不通过 FFI 直接调用,而是把解码放到鸿蒙侧线程,通过EventChannel异步回传。听起来很合理,但实际跑下来,EventChannel的数据传输开销比解码时间还大。一次 64x64 的 RGBA 数据量是 16KB,通道序列化加回声的开销大约两三毫秒,解码本身才 0.3 毫秒。完全是在为架构上的洁癖付性能税。

所以我的最终方案是:解码留在 Dart isolate,FFI 直调 Rust。这样既绕开了平台通道,又让解码不占用 UI 线程。

6. 性能验证与视觉体验优化

6.1 基准测试:光说丝滑没用,数字得出来

我用鸿蒙真机做了三轮对比测试,分别测:纯 Dart 实现、未优化的 FFI 调用(同步、UI 线程跑)、优化后的 FFI + isolate 调用。

方案32x32 解码耗时64x64 解码耗时列表滚动掉帧率
纯 Dart 实现9.8 ms35.2 ms12%
FFI 同步(UI 线程)0.9 ms3.4 ms1.5%
FFI + Isolate1.1 ms3.8 ms0.3%

FFI 同步在 UI 线程跑单次解码没问题,但批量场景下掉帧率还是上去了,最终采用 FFI + Isolate 的版本。注意,Isolate 多了数据拷贝的开销,所以单帧耗时比同步版略高,但掉帧率反而低很多,因为不阻塞 UI。

这个结果说明了两个道理:一是 Rust FFI 带来的性能提升是实打实的,解码时间从 9.8 毫秒降到 1 毫秒左右,整整一个数量级;二是 Flutter 里"不要在 UI 线程做任何计算"这个原则还是要遵守,哪怕计算再快,批量场景下也扛不住。

6.2 体验细节:缓存策略和降级方案

性能只是基础,视觉过渡要真正"丝滑",还得配合缓存策略。blurhash 解码出来的占位图不应该每次都重新解码——同一个列表项在滑动时可能多次进出可视区,如果每次进出都解码,那优化又打折扣。我用了一个简单的内存缓存 LruCache,键是hash + width + height,最多缓存 200 张,超出就淘汰最久未用的。这样同一张图反复进出视口,解码只需一次。

另外,blurhash 解码失败要有一个优雅的降级路径。有时候服务端返回的哈希字符串是坏的,或者包含非法字符,解码必然失败。我在 API 层面做了兜底:解码失败时,根据缓存过的平均色调生成一个纯色占位图,而不是让图片区域空白。用户看到纯色块虽然没那么有信息量,但至少布局是稳的,不会突然跳动。首次解码时间也算进了占位图的显著延迟,我在 build 阶段就提前把列表中所有可见卡片的 blurhash 提前解码完毕,而不是等图片组件构建时才现场解码。

在我自己的测试项目里,从列表快速滚动到加载完成,整个画面的变化是从模糊色块平滑过渡到清晰大图,几乎感知不到中间的空档。这个效果和纯色占位比起来,观感提升非常明显。如果你也在做鸿蒙上的 Flutter 应用,并且被图片加载体验折磨过,fast_blurhash 的这条路值得一试。

不过最后我也想给一句提醒:blurhash 解决的是"占位阶段"的体验,它不能替代真正的图片加载优化。如果原图本身加载就慢,再好的占位图也只是让用户少焦虑几秒钟。服务端缩略图、CDN 缓存、预加载策略这些基本功,该做还是得做。占位图是锦上添花,网络和缓存才是雪中送炭,两件事一起做,才真正治好了我的图片加载焦虑。

返回列表