普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月17日首页

手把手教你玩转HDS沉浸光感效果

作者 云_杰
2026年4月17日 10:44

鸿蒙开发干货——手把手教你玩转HDS沉浸光感效果

大家好,我是青蓝逐码的云杰。

最近有不少用户在交流时间到,应用底部 Tab 栏那种高级的“发光”和“沉浸”质感是怎么做出来的?

用户交流截图

用户交流截图

用户交流截图

在鸿蒙应用开发中,细腻的光影和材质表现确实是提升 UI 质感、打造沉浸式体验的关键一环。无论是底部 Tab 栏的毛玻璃光感,还是顶部导航栏的高级光效,掌握系统级材质能力都能让你的应用瞬间“高大上”。本文将带你解锁鸿蒙开发中 HDS(HarmonyOS Design System)沉浸光感材质 的全流程技巧,附完整代码示例和降级适配指南,让你的应用轻松玩转高级光影效果。

一、沉浸光感是什么?

从 HarmonyOS 6.1.0(23) 版本开始,@kit.UIDesignKit 为 HDS 组件引入了强大的 systemMaterialEffect(系统材质效果)能力。其中最吸引人的就是沉浸光感(IMMERSIVE)

与传统的纯色或简单的毛玻璃不同,沉浸光感会在组件内部模拟真实的物理光照模型。当你点击或与之交互时,会产生细腻的“光晕”和“反射”反馈,极大地增强了控件的立体感和触控反馈的真实度。

先来看看最终的实现效果:

沉浸光感效果图

沉浸光感效果图

目前,这套能力主要应用于两大核心组件:

  1. HdsNavigation:可为标题栏(TitleBar)设置沉浸光感。
  2. HdsTabs:可为底部的悬浮页签(TabBar)设置沉浸光感。

二、实战演练:为底部悬浮页签添加沉浸光感

接下来,我们以底部的 HdsTabs 为例,一步步实现沉浸光感效果。

1. 基础配置与准备

首先,确保你的工程引入了 UIDesignKit 相关的模块。我们需要 HdsTabsHdsTabsController 以及 hdsMaterial

import { hdsMaterial, HdsTabs, HdsTabsController } from "@kit.UIDesignKit";
import { SymbolGlyphModifier } from "@kit.ArkUI";

同时,我们定义好 Tab 栏的菜单配置(使用系统 Symbol 图标,支持多色渲染):

interface MenuItem {
  symbolGlyph: SymbolGlyphModifier;
  symbolGlyph1: SymbolGlyphModifier;
  label: string;
}

const MENU_CONFIG: MenuItem[] = [
  {
    symbolGlyph: new SymbolGlyphModifier($r("sys.symbol.clock"))
      .renderingStrategy(SymbolRenderingStrategy.MULTIPLE_COLOR)
      .fontColor([
        $r("sys.color.ohos_id_color_bottom_tab_icon_off"),
        $r("sys.color.ohos_id_color_bottom_tab_icon_auxcolor_off02"),
      ]),
    symbolGlyph1: new SymbolGlyphModifier($r("sys.symbol.clock_fill"))
      .renderingStrategy(SymbolRenderingStrategy.MULTIPLE_COLOR)
      .fontColor([
        $r("app.color.primary_blue"),
        $r("sys.color.ohos_id_color_primary_contrary"),
      ]),
    label: "待取",
  },
  // ... 其他 Tab 项配置
];

2. 核心方案一:使用系统自适应沉浸光感(官方推荐)

在绝大多数场景下,我们推荐使用 ADAPTIVE(自适应)模式。系统会根据当前设备的算力和性能状态,自动为你选择最佳的光效表现,保证流畅度的同时达到最优的视觉效果。

@Entry
@Component
struct Index {
  private hdsTabsController: HdsTabsController = new HdsTabsController();

  build() {
    HdsTabs({ controller: this.hdsTabsController }) {
      ForEach(MENU_CONFIG, (item: MenuItem, index: number) => {
        TabContent() {
          // 这里放你的页面内容,比如 PackagesPage()
        }
        .tabBar(new BottomTabBarStyle({
          normal: item.symbolGlyph,
          selected: item.symbolGlyph1
        }, item.label).labelStyle({
          selectedColor: $r('app.color.primary_blue') // 设置文字高亮色
        }))
      })
    }
    .barOverlap(true) // 允许内容延伸到 Tab 栏底部
    .barPosition(BarPosition.End)
    // 核心配置:开启悬浮样式并设置自适应材质
    .barFloatingStyle({
      barBottomMargin: 28,
      systemMaterialEffect: {
        materialType: hdsMaterial.MaterialType.ADAPTIVE,
        materialLevel: hdsMaterial.MaterialLevel.ADAPTIVE
      }
    })
  }
}

在这段代码中,最核心的就是 barFloatingStyle 里的 systemMaterialEffect 属性。我们将类型和级别都交给了系统 ADAPTIVE 去决策。

3. 核心方案二:强制自定义沉浸光感效果与降级处理

如果你对视觉有极高的要求,希望在支持的设备上强制开启最顶级的沉浸光效(例如点击时出现明显的“白光”反馈),你可以手动指定材质类型和级别。

避坑指南:并不是所有设备都支持高级的沉浸光感(IMMERSIVE)。如果强行在低端设备上开启,可能会导致严重的卡顿和发热。因此,必须先通过 API 查询设备能力,再进行优雅降级

import { hdsMaterial, HdsTabs, HdsTabsController } from '@kit.UIDesignKit';

@Entry
@Component
struct Index {
  private hdsTabsController: HdsTabsController = new HdsTabsController();
  // 默认我们想用最精致的效果
  @State customMaterialLevel: hdsMaterial.MaterialLevel = hdsMaterial.MaterialLevel.EXQUISITE;
  @State customMaterialType: hdsMaterial.MaterialType = hdsMaterial.MaterialType.IMMERSIVE;

  aboutToAppear(): void {
    // 1. 查询当前设备支持的系统材质类型
    let materialTypes: Array<hdsMaterial.MaterialType> = hdsMaterial.getSystemMaterialTypes();

    // 2. 检查是否支持 IMMERSIVE (沉浸光感)
    if (materialTypes.indexOf(hdsMaterial.MaterialType.IMMERSIVE) < 0) {
      // 3. 如果不支持,进行降级处理,使用基础的背景模糊平滑效果
      this.customMaterialType = hdsMaterial.MaterialType.BACKGROUND_BLUR;
      this.customMaterialLevel = hdsMaterial.MaterialLevel.SMOOTH;
    }
  }

  build() {
    HdsTabs({ controller: this.hdsTabsController }) {
      // ... TabContent 配置与之前相同
    }
    .barFloatingStyle({
      barBottomMargin: 28,
      systemMaterialEffect: {
        materialType: this.customMaterialType, // 应用查询后的类型
        materialLevel: this.customMaterialLevel // 应用查询后的级别
      }
    })
  }
}

关于材质级别的选择

  • EXQUISITE (精致):光影反馈最强烈、最细腻(比如点击时有明显点光源照射的高亮白光),但对性能要求最高。
  • GENTLE (柔和):光影反馈相对柔和内敛。
  • SMOOTH (平滑):性能开销最低,通常用于不支持复杂光照的降级场景。

三、总结

通过上述两种方案,我们可以非常轻松地为鸿蒙应用接入极具高级感的 HDS 沉浸光感材质。

  • 追求稳定和省心:无脑选择 ADAPTIVE
  • 追求极致视觉体验:使用 IMMERSIVE + EXQUISITE,但切记一定要做设备能力查询和降级处理hdsMaterial.getSystemMaterialTypes()),这是保证用户体验底线的关键。

同时,别忘了结合 deviceInfo.sdkApiVersion >= 23 来做好旧版本系统的兼容。

掌握了这些,你的应用 UI 质感绝对能上一个大台阶。赶紧去代码里试试吧!如果有任何疑问,欢迎在评论区交流。

昨天以前首页

Harmony NDK 开发

作者 阿健君
2026年4月13日 18:32

NDK(Native Development Kit) 是鸿蒙提供的原生开发工具集,允许开发者使用 C/C++ 编写底层代码,通过跨语言调用与 ArkTS 层交互。适用于性能敏感,复用C/C++库,底层硬件操作等场景。

创建 NDK 工程

可以直接使用 DevEco Studio 模板构建 NDK 工程

image.png

创建成功后,目录如下所示:

image.png

CMakeLists.txt 是鸿蒙原生 C++ 模块的构建配置文件,CMake 工具会根据它编译生成动态库(.so文件),供鸿蒙 ArkTS 层调用,我已经逐行解释含义了,不懂得直接看注释即可。

# 声明CMake所需的最低版本
cmake_minimum_required(VERSION 3.5.0)
# 定义项目名称
project(HarmonyApplication)
# 定义变量:CMAKE_CURRENT_SOURCE_DIR 为系统内置变量,代表当前 CMakeLists.txt 所在的文件夹路径
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})

# 判断是否定义了 PACKAGE_FIND_FILE 变量,若是则引入该文件,鸿蒙自动生成的兼容配置,用于加载依赖包的配置,开发者无需手动修改
if(DEFINED PACKAGE_FIND_FILE)
    include(${PACKAGE_FIND_FILE})
endif() # CMake 里 if 判断的结束标记,用来闭合 if 语句,CMake 不是 Java,没有大括号 {} 来圈定代码范围

# 添加头文件搜索路径:告诉 CMake,编译 C++ 代码时去这两个路径下查找头文件
include_directories(${NATIVERENDER_ROOT_PATH}
                    ${NATIVERENDER_ROOT_PATH}/include)

# 将 napi_init.cpp 编译成名为 entry 的动态库
# add_library:CMake 编译库文件的命令
# entry:最终生成的动态库名称(编译后会得到libentry.so)
# SHARED:指定生成动态共享库(鸿蒙 NAPI 必须用动态库)
# napi_init.cpp:要编译的 C++ 源文件
add_library(entry SHARED napi_init.cpp)

# 为动态库链接依赖库:让我们的动态库能调用鸿蒙 NAPI 接口,实现 C++ 与 ArkTS 的交互
target_link_libraries(entry PUBLIC libace_napi.z.so)

模块级 build-profile.json5 中 externalNativeOptions 参数是 NDK 工程 C/C++ 文件编译配置的入口

image.png

napi_init.cpp 是鸿蒙 NDK 的 “入口文件”,它是 C/C++ 代码 和 ArkTS/JS 代码之间的桥梁,没有它,ArkTS 就调用不了你的 C++ 方法。

它专门负责 3 件事:

  • 注册 Native 模块:告诉系统是一个 C++ 动态库
  • 绑定 C++ 函数:把你写的 C++ 方法暴露给 ArkTS
  • 提供调用入口:让 ArkTS 能像调用普通函数一样调用 C++
#include "napi/native_api.h"

//自定义的 C++ 方法(给 ArkTS 调用)
static napi_value Add(napi_env env, napi_callback_info info)
{
    size_t argc = 2;
    napi_value args[2] = {nullptr};

    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    napi_valuetype valuetype0;
    napi_typeof(env, args[0], &valuetype0);

    napi_valuetype valuetype1;
    napi_typeof(env, args[1], &valuetype1);

    double value0;
    napi_get_value_double(env, args[0], &value0);

    double value1;
    napi_get_value_double(env, args[1], &value1);

    napi_value sum;
    napi_create_double(env, value0 + value1, &sum);

    return sum;

}

//模块初始化:实现 ArkTS 接口与 C++ 接口的绑定和映射
EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports)
{
    napi_property_descriptor desc[] = {
        { "add", nullptr, Add, nullptr, nullptr, nullptr, napi_default, nullptr }
    };
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}
EXTERN_C_END

// 准备模块加载相关信息,将上述 Init 函数与本模块名等信息记录下来。
static napi_module demoModule = {
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = Init,
    .nm_modname = "entry",
    .nm_priv = ((void*)0),
    .reserved = { 0 },
};

// 加载 so 时,该函数会自动被调用,将上述 demoModule 模块注册到系统中。
extern "C" __attribute__((constructor)) void RegisterEntryModule(void)
{
    napi_module_register(&demoModule);
}

在 cpp\types\libentry\Index.d.ts 文件中,提供 JS 侧的接口方法

export const add: (a: number, b: number) => number;

在 oh-package.json5 文件中将 index.d.ts 与 cpp 文件关联起来

{
  "name": "libentry.so",
  "types": "./Index.d.ts",
  "version": "1.0.0",
  "description": "Please describe the basic information."
}

这些都是由 DevEco Studio 自动生成的,比如我们在 Index.d.ts 中定义一个方法

image.png

然后点击 Generate native implementation,它就能在 cpp 中自动生成对应的 C++ 方法和绑定

image.png

Node-API

  • napi_env:表示 Node-API 执行时的上下文,可以把它理解成 NAPI 给你的一张操作许可证 + 全套工具,所有 NAPI 函数都必须传入它。
  • napi_callback_info:代表 ArkTS 调用 C++ 函数时传递过来的所有信息,专门用来获取 ArkTS 传过来的参数。
  • napi_value:是一个C的结构体指针,表示一个 ArkTS/JS 对象的引用,可以理解为万能的数据载体,是 NAPI 统一的数据类型,可以表示字符串,数字,布尔,数组,对象,null,undefined 等等,C++ 和 ArkTS 之间传递数据只能用它,不能直接传 int,string,bool,必须包装成 napi_value。

这仨的关系,简言之:
ArkTS 调用 C++ 函数 -> 通过 info 拿到参数列表 -> 参数都是 napi_value 类型 -> 用 env 操作这些 napi_value -> 返回一个 napi_value 给 ArkTS

现在来实现一下上面定义的 NAPI_Global_getLast 方法,用来获取数组的最后一个元素。

static napi_value NAPI_Global_getLast(napi_env env, napi_callback_info info) {
    size_t argc = 1;
    napi_value args[1];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
    // 判断是否为数组
    bool isArray = false;
    napi_is_array(env, args[0], &isArray);
    if (isArray) {
        // 获取数组长度
        uint32_t arrayLength = 0;
        napi_get_array_length(env, args[0], &arrayLength);
        if (arrayLength > 0) {
            // 获取最后一个元素的索引
            uint32_t lastIndex = arrayLength - 1;
            // 获取数组最后一个元素
            napi_value lastElement;
            napi_get_element(env, args[0], lastIndex, &lastElement);
            // 获取字符串长度
            size_t strLen = 0;
            napi_get_value_string_utf8(env, lastElement, nullptr, 0, &strLen);
            // 读取字符串内容
            char resultStr[1024];
            napi_get_value_string_utf8(env, lastElement, resultStr, sizeof(resultStr), nullptr);
            napi_value returnValue;
            // NAPI_AUTO_LENGTH = 让 NAPI 自动计算字符串长度,不用你手动填数字
            napi_create_string_utf8(env, resultStr, NAPI_AUTO_LENGTH, &returnValue);

            return returnValue;
        }
    }
    return nullptr;
}

常用的 Napi 方法

获取调用信息(函数入口必用)

size_t argc = 1;
napi_value args[1];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

类型判断

napi_is_array:判断是不是数组

bool isArray = false;
napi_is_array(env, args[0], &isArray);

napi_typeof:判断类型

napi_valuetype type;
napi_typeof(env, args[0], &type);

取值

取字符串

char buf[1024];
napi_get_value_string_utf8(env, args[0], buf, sizeof(buf), nullptr);
std::string cppStr = buf;

取数字

double num;
napi_get_value_double(env, args[0], &num);

取整数

int num;
napi_get_value_int32(env, args[0], &num);

取布尔值

bool b;
napi_get_value_bool(env, args[0], &b);

创建值

// 创建数字
napi_value dNum;
napi_create_double(env, 100, &dNum);

// 创建整数
napi_value num;
napi_create_int32(env, 10, &num);

// 创建字符串
napi_value str;
napi_create_string_utf8(env, "Hello", NAPI_AUTO_LENGTH, &str);

// 创建布尔值
napi_value b;
napi_create_boolean(env, true, &b);

// 创建对象
napi_value obj;
napi_create_object(env, &obj);

// 创建数组
napi_value arr;
napi_create_array(env, &arr);

数组操作

// 获取数组长度
uint32_t len;
napi_get_array_length(env, arr, &len);

// 获取数组第 index 个元素
napi_value elem;
napi_get_element(env, arr, index, &elem);

// 设置数组第 index 个元素
napi_set_element(env, arr, index, elem);

对象操作

export const handleUser: (user: UserInfo) => UserInfo;

export interface UserInfo {
  name: string;
  age: number;
}
// ArkTS对象 → C++结构体
struct UserInfo {
    std::string name;
    int32_t age;
};


UserInfo ParseUser(napi_env env, napi_value object) {
    UserInfo info{};
    napi_value nameVal, ageVal;

    // 读取 name
    napi_get_named_property(env, object, "name", &nameVal);
    char nameBuff[64];
    size_t len;
    napi_get_value_string_utf8(env, nameVal, nameBuff, sizeof(nameBuff), &len);
    info.name = nameBuff;

    // 读取 age
    napi_get_named_property(env, object, "age", &ageVal);
    napi_get_value_int32(env, ageVal, &info.age);

    return info;
}

// C++ 结构体 -> ArkTs 对象
napi_value WrapUser(napi_env env, const UserInfo &info) {
    napi_value jsObject;
    napi_create_object(env, &jsObject);

    // 设置 name
    napi_value nameVal;
    napi_create_string_utf8(env, info.name.c_str(), NAPI_AUTO_LENGTH, &nameVal);
    napi_set_named_property(env, jsObject, "name", nameVal);

    // 设置 age
    napi_value ageVal;
    napi_create_int32(env, info.age, &ageVal);
    napi_set_named_property(env, jsObject, "age", ageVal);

    return jsObject;
}

static napi_value NAPI_Global_handleUser(napi_env env, napi_callback_info info) {
    size_t argc = 1;
    napi_value args[1];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
    
    // 解析入参
    UserInfo userInfo = ParseUser(env, args[0]);
    userInfo.age += 1;
    userInfo.name = "XZJ";
    
    return WrapUser(env, userInfo);
}

别让压图拖垮首帧:系统 Picker + TaskPool + ImagePacker,把 HarmonyOS 图片整理链路做顺

作者 李游Leo
2026年4月12日 22:01

这篇不聊“会不会调 API”,聊的是一个更像线上项目的问题:
用户选了十几张图,页面不能卡;处理完了,结果要能稳定回存;页面退到后台以后,上传也别说没就没。

image.png

我最近在做图片整理类功能时,重新把这条链路梳了一遍。以前很多写法放在 Demo 里没有问题,一旦到了真机、真用户、真批量场景,问题就全出来了:

  • 一上来就申请大权限,用户犹豫一下,转化率先掉一截。
  • 图片解码、缩放、编码全放主线程,选 8~10 张图时首帧明显发紧。
  • “保存成功”只是前端状态改了,文件其实还没稳定落盘。
  • 上传逻辑绑死在页面生命周期上,页面一退,任务也容易跟着断。

后来我换了一个思路:入口尽量交给系统 Picker,重活交给 TaskPool,编码收口到 ImagePacker,回存尽量走 SaveButton / 授权式保存,后台上传交给标准后台任务机制。

这个改完以后,最大的变化不是“代码更优雅”,而是整条用户路径没那么慌了。


一、先说结论:图片类功能别再把所有活都塞给页面

HarmonyOS 这几年给图片、文件、后台任务这些场景配的系统能力,其实已经很完整了。真正容易踩坑的,不是能力不够,而是链路设计还停留在“能跑就行”

对图片整理、票据扫描、相册工具、内容发布这类应用,我更推荐下面这套拆法:

  1. 入口层:优先系统 Picker / Camera Picker
    先把“拿到资源”这件事做轻,不要一上来就用全量权限思路。

  2. 处理层:解码、缩放、编码、哈希都丢到 TaskPool
    UI 线程只管状态切换、进度回写、结果展示。

  3. 结果层:临时文件先落应用沙箱
    不要一边处理一边直接改外部结果,失败以后很难回滚。

  4. 回存层:保存回相册时优先走 SaveButton / 授权保存
    这一步是“用户确认写入系统资源”,语义更清晰,也更稳。

  5. 后续层:上传 / 同步用标准后台任务机制托管
    页面销毁不等于业务任务应该立刻消失。

这套方案的核心不是“炫 API”,而是四个字:职责拆分


二、为什么这套链路更适合线上项目

image.png

我把它总结成一句话:

系统能力负责边界,业务代码负责规则。

1)资源入口尽量轻

很多图片工具第一步就想着“我要读图库,所以先申请图库权限”。
但从产品体验看,这一步往往太重了。

如果你的场景只是“让用户选择几张图来处理”,那更自然的做法是:

  • 让用户用系统 Picker 选图;
  • 用 Camera Picker 拍照;
  • 你的应用拿到 URI / 文件句柄以后再继续后面的业务。

这样做有两个好处:

第一,权限心智更轻。
第二,系统对资源边界更清晰,后续排查问题也更容易。

2)图片处理一定要和 UI 拆开

这是最关键的一点。

图片整理里最容易“偷懒”的地方,就是把下面这些步骤串着写在一个点击回调里:

  • 读文件
  • 解码成 PixelMap
  • 缩放
  • 编码
  • 写临时文件
  • 刷新列表

代码短期看挺顺,长期看基本就是掉帧制造机。
尤其是一次选多张图时,问题会非常明显。

所以我的建议一直很明确:

  • UI 线程只维护“当前选了什么、处理到哪一步、最终展示什么”。
  • 真正耗时的事全部下放到 TaskPool。

这时候你的页面就只剩三类状态:

  • UI 状态:按钮是否可点、当前显示哪个结果
  • 任务状态:压缩中 / 完成 / 失败 / 可重试
  • 文件状态:是否真的写出临时文件,是否可导出

一旦状态层次清楚了,很多“玄学 Bug”会立刻少一半。

3)结果先写到沙箱,再决定是否导出

很多人一开始会把“处理完成”直接等同于“保存完成”,这在工程上是两件事。

我一般会分两步:

  • 第一步:处理结果落到应用沙箱临时目录;
  • 第二步:用户确认后,再导出到系统相册或指定目录。

这么拆的好处很实际:

  • 失败时不会把外部结果弄脏;
  • 可以做失败重试;
  • 可以先预览结果,再决定要不要落到系统资源里;
  • 上传也可以直接基于沙箱内文件做,不必和“是否回存相册”强绑定。

三、给一个更像项目里的写法

下面这段不是“最短 Demo”,而是更接近业务项目里的组织方式。
我故意没有写成一屏代码,而是按模块拆开。这样后面你要加格式策略、清晰度分级、失败重试,都比较好扩。

说明:下面是 ArkTS 项目化示例,媒体 Picker、SaveButton 以及后台任务相关 import 在不同 SDK 版本中命名可能有细微差异,落地时请以你当前使用的 HarmonyOS SDK 文档为准。

1)定义任务数据结构

export interface CompressJob {
  id: string
  sourceUri: string
  targetFormat: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/heif'
  quality: number
  maxEdge: number
}

export interface CompressResult {
  id: string
  success: boolean
  tempFileUri: string
  width: number
  height: number
  message?: string
}

2)页面只维护状态,不直接做重活

@Entry
@Component
struct BatchImagePage {
  @State jobs: CompressJob[] = []
  @State results: CompressResult[] = []
  @State running: boolean = false
  @State progressText: string = '等待选择图片'

  async pickImages() {
    // 这里用系统媒体 Picker 选图
    // 实际 API 名称请以当前 SDK 为准
    const picker = new photoAccessHelper.PhotoViewPicker()
    const selected = await picker.select({
      maxSelectNumber: 12
    })

    this.jobs = selected.photoUris.map((uri: string, index: number) => ({
      id: `job_${Date.now()}_${index}`,
      sourceUri: uri,
      targetFormat: 'image/jpeg',
      quality: 78,
      maxEdge: 1600
    }))
    this.progressText = `已选择 ${this.jobs.length} 张图片`
  }

  async startCompress() {
    if (this.jobs.length === 0 || this.running) {
      return
    }
    this.running = true
    this.results = []

    for (let i = 0; i < this.jobs.length; i++) {
      const job = this.jobs[i]
      this.progressText = `处理中 ${i + 1}/${this.jobs.length}`

      try {
        const result = await ImagePipelineService.compressInTaskPool(job)
        this.results = [...this.results, result]
      } catch (err) {
        this.results = [
          ...this.results,
          {
            id: job.id,
            success: false,
            tempFileUri: '',
            width: 0,
            height: 0,
            message: JSON.stringify(err)
          }
        ]
      }
    }

    this.progressText = '处理完成'
    this.running = false
  }

  build() {
    Column({ space: 16 }) {
      Text('批量图片整理')
        .fontSize(26)
        .fontWeight(FontWeight.Bold)

      Text(this.progressText)
        .fontSize(14)
        .opacity(0.75)

      Row({ space: 12 }) {
        Button('选择图片').onClick(() => this.pickImages())
        Button(this.running ? '处理中...' : '开始整理')
          .enabled(!this.running && this.jobs.length > 0)
          .onClick(() => this.startCompress())
      }

      List() {
        ForEach(this.results, (item: CompressResult) => {
          ListItem() {
            Row({ space: 12 }) {
              Text(item.id).fontSize(14)
              Text(item.success ? '成功' : '失败')
                .fontColor(item.success ? '#26c281' : '#ff5d73')
              Text(item.message ?? item.tempFileUri)
                .fontSize(12)
                .opacity(0.7)
            }
          }
        })
      }
      .layoutWeight(1)
    }
    .padding(20)
    .width('100%')
    .height('100%')
  }
}

3)把解码、缩放、编码收口到服务层

export class ImagePipelineService {
  static async compressInTaskPool(job: CompressJob): Promise<CompressResult> {
    // 伪代码:把真正耗时的图片处理丢进 TaskPool
    return await taskpool.execute(compressWorker, job)
  }
}

4)Worker / TaskPool 里只做“纯处理”

@Concurrent
async function compressWorker(job: CompressJob): Promise<CompressResult> {
  // 伪代码:不同项目里你可能会把这部分继续拆成
  // read -> decode -> resize -> encode -> writeTempFile

  const sourceImage = image.createImageSource(job.sourceUri)
  const pixelMap = await sourceImage.createPixelMap()

  const size = calcTargetSize(pixelMap.getImageInfoSync(), job.maxEdge)
  const resizedPixelMap = await resizePixelMap(pixelMap, size.width, size.height)

  const packer = image.createImagePacker()
  const packedArrayBuffer = await packer.packing(
    resizedPixelMap,
    {
      format: job.targetFormat,
      quality: job.quality
    }
  )

  const tempFileUri = await writeBufferToCache(job.id, packedArrayBuffer)

  return {
    id: job.id,
    success: true,
    tempFileUri,
    width: size.width,
    height: size.height
  }
}

5)保存不要和“处理完成”强绑死

@Component
struct ExportPanel {
  @Prop fileUri: string

  build() {
    Column({ space: 12 }) {
      Text('处理完成后,可以先预览,再决定是否导出到系统相册。')

      // 实际项目里优先考虑安全组件 / SaveButton 的方案
      SaveButton({
        text: '保存到相册'
      })
      .onClick(async () => {
        await exportResultToGallery(this.fileUri)
      })
    }
  }
}

四、这套写法最容易忽略的 4 个细节

image.png

细节 1:不要只存“任务成功”,要存“文件可用”

很多代码会写成这样:

  • TaskPool 返回成功
  • 列表显示“已完成”
  • 用户点击导出
  • 结果发现文件并不可读

这类问题本质上是:
你把“任务返回成功”和“结果文件可用”混成了一件事。

更稳的做法是分开记:

  • taskStatus
  • fileStatus
  • exportStatus

状态一旦拆开,排查就快很多。

细节 2:格式策略不要写死

图片场景里,格式不是越统一越好,而是要看业务目标

我自己一般会这么分:

  • 追求通用分享:优先 JPEG
  • 需要透明背景:保留 PNG
  • 更关注体积:视兼容性考虑 WebP / HEIF
  • 要保细节或特殊能力:另走高质量策略

所以我不太建议在项目初期就写死成“所有图都压成 JPEG 80”。
后面你只要接到一次“为什么透明背景没了”的反馈,就会明白这个坑有多真实。

细节 3:页面状态不要和上传状态绑在一起

很多项目会在图片处理页里顺手把上传也一起做掉。
从流程上看没毛病,从生命周期看问题很大。

更推荐的方式是:

  • 页面内只负责发起上传任务;
  • 上传状态由任务系统维护;
  • 页面返回后,再次进入时读取任务状态。

这样你才能真正做到:

  • 页面退了,任务还在;
  • 网络恢复了,任务还能继续;
  • 失败了,可以单独重试,不必重新压图。

细节 4:别把“系统资源访问”当普通本地文件读写

很多 Bug 到最后都不是图像算法问题,而是 URI、授权、资源归属、目录可见性这些边界没想清楚。

尤其是下面几件事,最好一开始就想明白:

  • 你拿到的是临时 URI 还是长期可读资源?
  • 压缩结果先落哪里?
  • 谁来触发最终导出?
  • 导出失败后是否还能重试?
  • 用户没点保存,但点了上传,这是不是允许?

这些问题想通以后,代码层面的复杂度反而会下降。


五、给一个我自己现在更认可的工程拆法

image.png

如果让我现在从 0 到 1 再搭一次图片整理功能,我会直接按下面分层:

1)UI 层

只负责:

  • 选择入口
  • 任务列表展示
  • 进度与错误提示
  • 预览结果
  • 用户点击导出 / 上传

2)任务编排层

只负责:

  • 队列管理
  • 串行 / 并行策略
  • 失败重试
  • 结果状态汇总

3)图像处理层

只负责:

  • 解码
  • 缩放
  • 编码
  • 哈希 / 校验
  • 临时文件输出

4)资源与系统能力层

只负责:

  • Picker 获取资源
  • SaveButton / 授权保存
  • 后台任务托管
  • 文件系统 / URI 管理

这样一来,后面你要加这些功能都很自然:

  • 批量水印
  • 多规格导出
  • 上传前秒传校验
  • 压缩失败自动降级
  • 低电量 / 弱网场景策略切换

六、最后说一句真话:图片功能拼的不是“压缩算法”,是链路稳定性

很多时候大家讨论图片处理,第一反应都是:

  • 质量参数设多少?
  • 压缩率能不能更高?
  • WebP 和 JPEG 哪个更小?

这些当然重要,但在实际项目里,用户第一时间感知到的并不是“你比别人小了 8%”,而是:

  • 点了以后会不会卡
  • 处理过程中会不会慌
  • 保存会不会莫名失败
  • 退到后台以后会不会白跑

所以我现在更在意的排序是:

  1. 不卡
  2. 稳定
  3. 边界清楚
  4. 最后才是压得漂亮

这也是我为什么越来越倾向于把系统 Picker、TaskPool、ImagePacker、SaveButton、后台任务这些能力串成一条完整链路,而不是各自零散使用。

真正上线以后你会发现,工程上的“顺”,比算法上的“狠”更值钱。

【Flutter×鸿蒙】通关手册(二):FVM 不认鸿蒙 SDK?4步手动塞进去

作者 TT_Close
2026年3月5日 18:26

系列导航:

我第一次让 FVM 管理鸿蒙版 Flutter SDK 时,前后踩了 4 个坑,花了大半天才跑通。事后复盘发现,每个坑都不难,只是没人提前告诉我"为什么要这样做"。这篇把整个过程拆成 5 关,每关讲清「为什么」和「怎么做」,争取让你 20 分钟一次通关。

前置条件:请先完成第一篇的全部内容——DevEco Studio 已安装,ohpm、node、hvigorw 在终端里都能正常调用。


🗺️ 通关路线图

关卡 任务 预计耗时
第1关 安装 FVM 2 min
第2关 克隆鸿蒙版 SDK 5 min(取决于网速)
第3关 修复版本"身份证" 3 min
第4关 指定鸿蒙 SDK 路径 1 min
第5关 全绿验证 2 min

🎯 第 1 关:安装 FVM

目标

让终端认识 fvm 命令。

为什么需要 FVM

一句话——让不同项目用不同版本的 Flutter,互不干扰。比如项目 A 用官方 3.24 跑 Android/iOS,项目 B 用鸿蒙版 3.35.8。FVM 就是 Flutter 的"版本档案柜",每个抽屉放一个版本。

📋 操作

# macOS(在终端里执行,这是用 Homebrew 包管理器安装 FVM)
brew install fvm
# Windows(在 cmd 或 PowerShell 中执行,这是用 Chocolatey 包管理器安装 FVM)
choco install fvm

安装完后,配置 FVM 缓存路径。把以下两行写入 ~/.zshrc(上一篇介绍过,这是 Mac 终端的配置文件):

# FVM 存放所有 Flutter 版本的目录
export FVM_CACHE_PATH=$HOME/fvm
# 让 FVM 的默认版本可以直接用 flutter 命令调用
export PATH="$HOME/fvm/default/bin:$PATH"

保存后执行下面这条命令,让刚才的配置立即生效(否则要关掉终端重新打开):

source ~/.zshrc

✅ 验证

# 查看 FVM 版本号,确认安装成功
fvm --version

看到版本号(如 3.1.4)就过关了。

⚠️ 如果报 command not found:Mac 用户确认已安装 Homebrew(执行 brew --version 看有没有输出);Windows 用户确认已安装 Chocolatey(执行 choco --version)。如果包管理器本身都没装,请先去官网安装。


🎯 第 2 关:克隆鸿蒙版 SDK

目标

把华为的鸿蒙版 Flutter 放进 FVM 管辖。

为什么不能直接 fvm install

正常装 Flutter 只需要 fvm install 3.24.0,FVM 会自动去 GitHub 下载。但鸿蒙版是华为团队在 AtomGit(国内代码托管平台)上单独维护的,FVM 的世界里它根本不存在。所以我们要"手动入库"——自己下载代码,放到 FVM 的档案柜里,假装它一直在那。

⚠️ 本关最大的坑:分支名和版本号是两回事!

仓库的分支叫 oh-3.35.7-dev,看到 3.35.7 你会以为版本就是 3.35.7。但实际上代码里的版本已经迭代到了 3.35.8-ohos-0.0.2

类比:Git 分支叫 feature/login-v1,但代码早就改到 v3 了。分支名是创建时起的,不会跟着版本号自动更新。

千万别拿分支名当版本号用,团队必须统一用 3.35.8-ohos-0.0.2 这个真实版本号。

📋 操作

# --depth 1 只取最新代码,省空间(省去几个 GB 的历史记录)
git clone -b oh-3.35.7-dev --depth 1 
https://atomgit.com/openharmony-tpc/flutter_flutter.git 
~/fvm/versions/3.35.8-ohos-0.0.2

注意看:clone 命令里分支名是 oh-3.35.7-dev,但目标文件夹名是 3.35.8-ohos-0.0.2——这不是写错了,上面已经解释了为什么不一样。

💡 怎么确认真实版本号? clone 完后执行 ~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter --version 看输出。如果加入已有团队,直接看项目的 .fvmrc 文件(命令:cat .fvmrc)。

✅ 验证

# 确认文件下载成功(ls = 列出目录内容)
ls ~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter

文件存在就过关。

⚠️ 如果报 No such file or directory:回去检查 clone 命令是否执行成功。常见原因是网络超时(AtomGit 在国内,通常不需要梯子,但公司内网可能有限制)。重新执行 clone 前,先删掉残留目录:rm -rf ~/fvm/versions/3.35.8-ohos-0.0.2,再重试。


🎯 第 3 关:修复版本"身份证"

目标

让 FVM 正确识别这个 SDK 的版本号。

为什么要做这步

clone 下来的 SDK 有两张"证件":

  1. version 文件——相当于身份证,一行文本写着版本号
  2. bin/cache/flutter.version.json——相当于内部档案,JSON 格式的详细版本信息

问题是,这两张证件上都写着 0.0.0-unknown(因为鸿蒙团队是从开发分支构建的,没有打标准标签)。但我们的文件夹名叫 3.35.8-ohos-0.0.2。FVM 一查——名字对不上,直接翻脸。

⚠️ 不做这步的后果:FVM 会弹出 "Version mismatch" 并试图删掉你的 SDK 重装。如果看到了这个弹窗,千万不要选任何选项,按 Ctrl+C(Mac 也是 Ctrl 不是 Cmd)退出,回来做这步。

📋 操作

macOS / Linux:

cd ~/fvm/versions/3.35.8-ohos-0.0.2

# 第一步:改"身份证"
echo -n "3.35.8-ohos-0.0.2" > version

# 第二步:初始化 Flutter 引擎(首次运行会下载 Dart SDK,需要等 1-3 分钟)
bin/flutter --version

# 第三步:改"内部档案"(把所有 0.0.0-unknown 替换成正确的版本号)
sed -i '' 's/0.0.0-unknown/3.35.8-ohos-0.0.2/g' bin/cache/flutter.version.json

Windows PowerShell:

# 进入 SDK 所在目录
cd $env:USERPROFILE\fvm\versions\3.35.8-ohos-0.0.2

# 第一步:改"身份证"
"3.35.8-ohos-0.0.2" | Set-Content version -NoNewline

# 第二步:初始化引擎
bin\flutter --version

# 第三步:改"内部档案"(PowerShell 的查找替换写法)
(Get-Content bin\cache\flutter.version.json) -replace '0.0.0-unknown', '3.35.8-ohos-0.0.2' | Set-Content bin\cache\flutter.version.json

⚠️ 三步的顺序不能乱——第二步会生成 flutter.version.json 文件,第三步才有东西可改。如果你先执行了第三步,会报文件不存在。

✅ 验证

# 回到任意目录都可以执行(fvm list = 列出 FVM 管理的所有 Flutter 版本)
fvm list

看到 Version 列显示 3.35.8-ohos-0.0.2(不是空白、不是 Need setup、不是 0.0.0-unknown),这关就过了。

02_fvm_list.png ⚠️ 如果还是显示异常,逐一排查两张"证件":

# 检查"身份证"内容
cat ~/fvm/versions/3.35.8-ohos-0.0.2/version
# 应该输出:3.35.8-ohos-0.0.2(没有多余空行)

# 检查"内部档案"有没有残留的 0.0.0-unknown
cat ~/fvm/versions/3.35.8-ohos-0.0.2/bin/cache/flutter.version.json
# 里面所有 version 字段应该都是 3.35.8-ohos-0.0.2

如果 version 文件内容不对,重新执行第一步;如果 JSON 里还有 0.0.0-unknown,重新执行第三步。


🎯 第 4 关:指定鸿蒙 SDK 路径

目标

让 Flutter 知道鸿蒙的 SDK(OpenHarmony SDK)装在哪。

为什么不用环境变量

我试过 HOS_SDK_HOMEOHOS_SDK_HOME 等环境变量,时灵时不灵。原因是不同方式打开的终端(VS Code 内置终端 vs 系统终端 vs CI 环境)加载配置文件的顺序不一样,变量可能没被读到。flutter config 会把路径写入 Flutter 自己的配置文件,不管从哪里启动都能读到,最稳。

📋 操作

# 把鸿蒙 SDK 的位置"写死"到 Flutter 的配置里(一次性操作)
~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter config \
--ohos-sdk="/Applications/DevEco-Studio.app/Contents/sdk"

⚠️ Windows 用户路径改为:--ohos-sdk="C:\Program Files\Huawei\DevEco Studio\sdk"

请根据 DevEco Studio 实际安装路径调整。不确定装在哪?打开 DevEco Studio → Settings → SDK 页面可以看到路径。

终端输出 Setting "ohos-sdk" value to "..." 就成功了。

✅ 验证

不急,下一关一起验收。


🎯 第 5 关:全绿验证

目标

flutter doctor 中 HarmonyOS toolchain 一栏显示绿色对勾。

📋 操作

# 运行 Flutter 的环境诊断工具(-v 表示显示详细信息)
~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter doctor -v

✅ 验证

关注输出中的 HarmonyOS 那一栏:

[✓] HarmonyOS toolchain - develop for HarmonyOS devices
    • OpenHarmony Sdk at /Applications/DevEco-Studio.app/Contents/sdk,
      available api versions has [22:default]
    • Ohpm version 6.0.1
    • Node version v18.20.1
    • Hvigorw binary at .../hvigor/bin/hvigorw

看到 [✓] 加上 4 个子项都有值 = 通关!

02_flutter_doctor.png 💡 你可能会看到 Flutter 那栏有几个 ! 警告(channel 不标准、upstream 不是官方地址)。这是鸿蒙版的正常现象,完全不影响开发和打包,放心忽略。

⚠️ 如果 HarmonyOS 那栏还是红叉,按优先级排查:

  1. SDK not found → 回第 4 关检查 config 路径是否正确
  2. ohpm/hvigorw missing → 回第一篇检查环境变量
  3. Version mismatch → 回第 3 关检查两张"证件"

🔧 附加关:FVM 的"碎碎念"

通关后你会发现,每次用 fvm flutter xxx 时 FVM 都会弹 "not a valid version" 的警告让你确认。这不是报错,只是 FVM 在说:"这个版本号我在官方列表里查不到,你确定要用吗?"

三种应对方式:

  1. 手动按 y——每次弹出输入 y 回车
  2. 自动确认——命令前加 yes |
yes | fvm flutter doctor
  1. 绕过 FVM——直接用绝对路径调用,完全不弹警告:
~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter doctor

我推荐第三种,路径虽长但最省心。可以设个快捷方式(alias)缩短它:

# 把这行加到 ~/.zshrc 里(alias = 给一条长命令起个短名字)
alias hflutter="$HOME/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter"

保存后 source ~/.zshrc,之后直接 hflutter runhflutter doctor 就行。


🏆 通关总结

项目 状态
FVM ✅ 已安装
鸿蒙版 Flutter SDK ✅ ~/fvm/versions/3.35.8-ohos-0.0.2
version 文件 ✅ 已修复
flutter.version.json ✅ 已修复
flutter config --ohos-sdk ✅ 已配置
flutter doctor HarmonyOS ✅ 全绿

回顾核心逻辑:FVM 只管官方 Flutter,鸿蒙版要我们手动塞进去(第 2 关);塞进去后"证件"信息对不上,需要手动修正(第 3 关);最后告诉 Flutter 鸿蒙 SDK 在哪(第 4 关)。理解了这条线,以后鸿蒙版 SDK 升级换版本号,你也能照样搞定。

如果中途卡住,大概率是版本号写错了——检查文件夹名、version 文件内容、flutter.version.json 里的版本号,三者必须完全一致


下一篇预告:SDK 准备好了,接下来要把你的老 Flutter 项目跑到鸿蒙上——听起来就是敲几行命令的事?没那么简单。→ 【Flutter×鸿蒙】通关手册(三):debug 包也要签名,这点和 Android 差远了

❌
❌