HarmonyOS鸿蒙Next中PixelMap更新后如何触发Image更新
HarmonyOS鸿蒙Next中PixelMap更新后如何触发Image更新 我目前有一个小应用每0.5秒用websocket接收一个rgba8888的64x64的小图并在屏幕上显示出来,但PixelMap在writeBufferToPixelsSync之后并不能触发Image更新。目前我是在页面里维护两个private的PixelMap和一个Local的PixelMap变量用在Image组件上,每次收到新图后写Local不指向的那个PixelMap然后把Local赋值成那个PixelMap,不知道有没有更好的解决方案呢?
开发者您好,调用writeBufferToPixelsSync方法仅更新像素数据,不会触发ArkUI状态管理机制的监听,导致无法更新。您可以每次更新都创建一个新变量进行赋值,从而触发ArkUI状态刷新。如以上方案仍然无法解决问题,请提供可以复现问题的demo,方便问题分析解决。
更多关于HarmonyOS鸿蒙Next中PixelMap更新后如何触发Image更新的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html
Image 组件更容易响应的是 PixelMap 引用变化,而不是同一个 PixelMap 内部 buffer 被原地写入。writeBufferToPixelsSync 改了像素数据,但如果绑定到 Image 的对象引用没有变化,UI 层不一定会认为需要重新取图。
你现在用双缓冲 PixelMap,再把 @State/@Local 里绑定的引用切到另一个 PixelMap,本质上是可行的。高频刷新时也可以继续用这种方式,注意控制帧率和释放旧资源。
如果后续帧率更高或尺寸更大,可以评估 Canvas/XComponent/native 渲染链路;如果只是低频小图更新,双缓冲或每次生成新 PixelMap 引用通常比强行刷新同一个对象更稳。
可以看看官方demo:
https://gitcode.com/HarmonyOS_Samples/ImageGetAndSave
/*
* Copyright (c) 2024 Huawei Device Co., Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* 最佳实践:图片获取与保存实践
*/
import { BusinessError } from '@kit.BasicServicesKit';
import { cameraPicker, camera } from '@kit.CameraKit';
import { picker } from '@kit.CoreFileKit';
import { image } from '@kit.ImageKit';
import { PhotoPickerComponent, PickerController, photoAccessHelper, ReminderMode } from '@kit.MediaLibraryKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { copyImg2Sandbox, pixelMap2File } from '../common/utils/Utils';
const TAG = 'IMAGE_APP';
@Entry
@Component
struct Index {
@State path: string = this.getUIContext().getHostContext()!.filesDir + '/image.jpg';
@State pixelMapPath: string = this.getUIContext().getHostContext()!.filesDir + '/pixelMap.jpg';
@State imageUri: string | undefined = undefined;
@State imageSource: image.ImageSource | undefined = undefined;
@State pixelMap: image.PixelMap | undefined = undefined;
@State isShowGet: boolean = false;
@State isShowPicker: boolean = false;
@State isShowSave: boolean = false;
@State pickerController: PickerController = new PickerController();
@Builder
getImage() {
Column({ space: 12 }) {
Button($r('app.string.get_image_from_component'))
.width('100%')
.height(40)
.onClick(() => {
this.isShowPicker = true;
})
.bindSheet($$this.isShowPicker, this.photoPicker(), {
height: SheetSize.FIT_CONTENT,
title: { title: $r('app.string.title_get_image_from_component') }
})
Button($r('app.string.get_image_from_photo_picker'))
.width('100%')
.height(40)
.onClick(async () => {
try {
let PhotoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
PhotoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
PhotoSelectOptions.maxSelectNumber = 1;
let photoPicker = new photoAccessHelper.PhotoViewPicker();
photoPicker.select(PhotoSelectOptions).then((PhotoSelectResult: photoAccessHelper.PhotoSelectResult) => {
this.imageUri = PhotoSelectResult.photoUris[0] ? PhotoSelectResult.photoUris[0] : this.imageUri;
hilog.info(0x0000, TAG, 'PhotoViewPicker.select succeed, uri: ' + JSON.stringify(PhotoSelectResult));
}).catch((err: BusinessError) => {
hilog.error(0x0000, TAG, `PhotoViewPicker.select failed, error: ${err.code}, ${err.message}`);
});
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(0x0000, TAG, `PhotoViewPicker failed, error: ${err.code}, ${err.message}`);
}
this.isShowGet = false;
})
Button($r('app.string.get_image_from_camera_picker'))
.width('100%')
.height(40)
.onClick(async () => {
// [Start pick_file]
try {
let pickerProfile: cameraPicker.PickerProfile =
{ cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK };
//Select the action of pulling up the camera to take pictures.
let pickerResult: cameraPicker.PickerResult = await cameraPicker.pick(this.getUIContext().getHostContext(),
[cameraPicker.PickerMediaType.PHOTO], pickerProfile);
//Return the photo uri to the application.
this.imageUri = pickerResult.resultUri ? pickerResult.resultUri : this.imageUri;
hilog.info(0x0000, TAG, 'cameraPicker.pick succeed, uri: ' + JSON.stringify(pickerResult));
} catch (error) {
let err = error as BusinessError;
hilog.error(0x0000, TAG, `cameraPicker.pick failed, error: ${err.code}, ${err.message}`);
}
// [End pick_file]
this.isShowGet = false;
})
Button($r('app.string.get_image_from_document_picker'))
.width('100%')
.height(40)
.onClick(() => {
try {
let documentSelectOptions = new picker.DocumentSelectOptions();
documentSelectOptions.maxSelectNumber = 1;
let documentPicker = new picker.DocumentViewPicker(this.getUIContext().getHostContext()!);
documentPicker.select(documentSelectOptions).then((documentSelectResult: Array<string>) => {
hilog.info(0x0000, TAG,
'DocumentViewPicker.select succeed, uri: ' + JSON.stringify(documentSelectResult));
if (documentSelectResult[0].endsWith('.jpg') || documentSelectResult[0].endsWith('.png')) {
this.imageUri = documentSelectResult[0] ? documentSelectResult[0] : this.imageUri;
} else {
this.getUIContext().getPromptAction().showToast({
message: $r('app.string.document_alert'),
duration: 2000
})
}
}).catch((err: BusinessError) => {
hilog.error(0x0000, TAG, `DocumentViewPicker.select failed, error: ${err.code}, ${err.message}`);
});
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(0x0000, TAG, `DocumentViewPicker failed, error: ${err.code}, ${err.message}`);
}
this.isShowGet = false;
})
}
.width('100%')
.height(196)
.padding({ left: 16, right: 16 })
.margin({ top: 16, bottom: 44 })
.justifyContent(FlexAlign.SpaceBetween)
}
onSelect(uri: string): void {
if (uri) {
this.imageUri = uri;
}
}
@Builder
photoPicker() {
Column({ space: 12 }) {
Scroll() {
PhotoPickerComponent({
pickerOptions: {
maxPhotoSelectNumber: 1,
maxVideoSelectNumber: 0,
maxSelectedReminderMode: ReminderMode.TOAST,
},
onSelect: (uri: string) => this.onSelect(uri),
pickerController: this.pickerController
})
.width('100%')
.height('100%')
}
.width('100%')
.height(400)
Column() {
Button($r('app.string.confirm'))
.width('100%')
.height(40)
.onClick(() => {
this.isShowPicker = false;
this.isShowGet = false;
})
}
.width('100%')
.height(40)
.padding({ left: 16, right: 16 })
}
.width('100%')
.height(452)
.margin({ top: 16, bottom: 44 })
.justifyContent(FlexAlign.SpaceBetween)
}
@Builder
saveImage() {
Column({ space: 12 }) {
Button($r('app.string.save_image_in_sandbox'))
.width('100%')
.height(40)
.onClick(async () => {
if (!this.pixelMap) {
this.showToast($r('app.string.no_pixel_map_alert'), 2000);
return;
}
await pixelMap2File(this.pixelMap, this.path);
this.showToast($r('app.string.save_in_sandbox_success'), 2000);
this.isShowSave = false;
})
SaveButton({ text: SaveDescription.SAVE_TO_GALLERY })
.width('100%')
.height(40)
.onClick(async (event, result: SaveButtonOnClickResult) => {
if (!this.pixelMap) {
this.showToast($r('app.string.no_pixel_map_alert'), 2000);
return;
}
if (result === SaveButtonOnClickResult.SUCCESS) {
try {
await pixelMap2File(this.pixelMap, this.path);
let context = this.getUIContext().getHostContext();
let phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
let assetChangeRequest: photoAccessHelper.MediaAssetChangeRequest =
photoAccessHelper.MediaAssetChangeRequest.createImageAssetRequest(context, this.path);
await phAccessHelper.applyChanges(assetChangeRequest);
this.getUIContext().getPromptAction().showToast({
message: $r('app.string.save_in_gallery_success'),
duration: 2000
});
} catch (err) {
hilog.error(0x0000, TAG, 'createAsset failed, error: ' + JSON.stringify(err));
}
} else {
hilog.error(0x0000, TAG, 'SaveButtonOnClickResult create asset failed.');
}
this.isShowSave = false;
})
}
.width('100%')
.height(92)
.padding({ left: 16, right: 16 })
.margin({ top: 16, bottom: 44 })
.justifyContent(FlexAlign.SpaceBetween)
}
build() {
Navigation() {
Column() {
Image(this.imageUri)
.height(350)
.margin({ top: 16 })
Column({ space: 12 }) {
Button($r('app.string.get_image'))
.width('100%')
.height(40)
.onClick(() => {
this.isShowGet = true;
})
.bindSheet($$this.isShowGet, this.getImage(), {
height: SheetSize.FIT_CONTENT,
title: { title: $r('app.string.get_image') }
})
Button($r('app.string.get_image_info'))
.width('100%')
.height(40)
.onClick(() => {
if (!this.imageUri) {
this.showToast($r('app.string.no_image_alert'), 2000);
return;
}
copyImg2Sandbox(this.imageUri, this.path).then(() => {
// [Start image_source]
this.imageSource = image.createImageSource(this.path);
this.imageSource.getImageInfo((error: BusinessError, imageInfo: image.ImageInfo) => {
if (error) {
hilog.error(0x0000, TAG, `getImageInfo failed, error: ${error.code}, ${error.message}`);
} else {
hilog.info(0x0000, TAG, 'getImageInfo succeed, info: ' + JSON.stringify(imageInfo));
}
});
// [End image_source]
// [Start image_proper]
let key = [image.PropertyKey.IMAGE_WIDTH, image.PropertyKey.IMAGE_LENGTH, image.PropertyKey.F_NUMBER];
this.imageSource.getImageProperties(key).then((data) => {
hilog.info(0x0000, TAG, 'getImageProperties succeed, data: ' + JSON.stringify(data));
}).catch((error: BusinessError) => {
hilog.error(0x0000, TAG, 'getImageProperties failed, error: ' + JSON.stringify(error));
});
// [End image_proper]
});
this.showToast($r('app.string.get_image_info_success'), 2000);
})
Button($r('app.string.get_pixel_map'))
.width('100%')
.height(40)
.onClick(() => {
if (!this.imageSource) {
this.showToast($r('app.string.no_image_info_alert'), 2000);
return;
}
this.imageSource.createPixelMap().then((pixelMap: image.PixelMap) => {
this.pixelMap = pixelMap;
this.showToast($r('app.string.get_pixel_map_success'), 2000);
}).catch((error: BusinessError) => {
hilog.error(0x0000, TAG, 'createPixelMap failed, error: ' + JSON.stringify(error));
})
})
Button($r('app.string.save_image'))
.width('100%')
.height(40)
.onClick(() => {
this.isShowSave = true;
})
.bindSheet($$this.isShowSave, this.saveImage(), {
height: SheetSize.FIT_CONTENT,
title: { title: $r('app.string.save_image') }
})
}
.width('100%')
.height(196)
.padding({ left: 16, right: 16 })
.margin({ bottom: 16 })
.justifyContent(FlexAlign.SpaceBetween)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
.width('100%')
.height('100%')
.title($r('app.string.title'))
}
showToast(message: ResourceStr, duration?: number) {
try {
this.getUIContext().getPromptAction().showToast({
message: message,
duration: duration
});
} catch (error) {
let err = error as BusinessError;
hilog.error(0x0000, 'Index', `showToast failed, error code=${err.code}, message=${err.message}`);
}
}
}
HarmonyOS的社区里有很多技术大牛分享经验,学到了很多有用的知识。
- rgba8888也就是4个8位存储图片像素信息,所以64x64的小图大概646432/8个字节,再加上图片其他信息算大概20K左右吧。图片很小,不用双buffer(你的双PixelMap),另外服务器也不会把20K的图片拆开分次发。
- 0.5秒是服务端每隔0,5秒给你发一次图片数据吧,这个间隔足够你处理完图片数据并显示了。一个PixelMap状态变量就行。
webSocket.on收到arraybuffer直接通过image.createImageSource获取图片信息,然后创建PixelMap并赋给状态变量就行。你用Local,V2状态管理,大概如下:
import { webSocket } from '@kit.NetworkKit'
import { image } from '@kit.ImageKit';
@Entry
@ComponentV2
struct WebsocketPage{
@Local imgPixelMap:image.PixelMap | undefined = undefined;
doSocket(){
const ws = webSocket.createWebSocket();
//TODO .......
ws.on('message', async (err,data) => {
if (!err.code) {
if (data instanceof ArrayBuffer) {
let imageSource = image.createImageSource(data);
let pixelMap = await imageSource.createPixelMap({desiredPixelFormat:image.PixelMapFormat.RGBA_8888});
this.imgPixelMap = pixelMap;
}else {
console.info('接收字符串:' + data);
}
} else {
console.info('错误:' + JSON.stringify(err));
}
});
//TODO .....
}
build() {
Column(){
Image(this.imgPixelMap)
.width(200)
.height(200)
Button('连接获取')
.onClick(()=>{
this.doSocket();
})
}
}
}
你现在这种“双 PixelMap + 切换引用”的方案,其实已经是 HarmonyOS NEXT 里比较常见的做法了。
因为目前:
pixelMap.writeBufferToPixelsSync()
只会修改 PixelMap 内部像素数据,
但不会通知 ArkUI:
“这个 Image 需要重新刷新”
所以:
Image(this.xxxPixelMap)
绑定的对象引用没变时,UI 不会重绘。
⸻
也就是说:
目前 ArkUI 对 PixelMap 更像:
按引用刷新
不是按内容刷新
所以你现在:
A PixelMap 写数据
↓
切换 Local 引用
↓
Image 重新build
这是有效方案。
⸻
你现在这种:
双缓冲(double buffer)
实际上是比较合理的。
优点:
- 不会闪屏
- 避免正在显示时写入
- UI刷新稳定
很多实时视频流/远程桌面也是这么干的。
⸻
不过可以稍微优化一下。
你没必要维护:
两个 private PixelMap
+ 一个 Local PixelMap
可以直接:
@State currentPixelMap?: image.PixelMap
然后:
this.currentPixelMap = nextPixelMap
只要引用变化:
Image 就会刷新
⸻
另外一种方案是:
每次新建 PixelMap:
image.createPixelMapSync(...)
然后直接替换。
但你这个:
0.5秒一次
64x64
频率虽然不高,
但长期频繁 create/destroy:
会增加:
- GC压力
- Native内存抖动
- UI卡顿
所以不如你现在这种双缓冲稳定。
⸻
还有一种更底层的方案:
用:
- XComponent
- NativeDrawing
- OpenGL
- GPU纹理更新
直接画 rgba buffer。
这种才是真正适合:
高频图像流
的方案。
因为:
Image + PixelMap
本质还是偏静态图片组件。
如果后面你频率提高到:
- 30fps
- 60fps
- 视频流
- 远程桌面
建议直接走:
XComponent + EGL/OpenGL
否则 UI 刷新成本会越来越高。
⸻
一句话:
目前 ArkUI 的 Image 不会因为 writeBufferToPixelsSync() 自动刷新,必须让绑定的 PixelMap 引用发生变化;你现在的双缓冲方案其实是正确方向,高频场景建议后续升级到 XComponent/OpenGL。
@State声明不是自动刷新UI吗
在HarmonyOS Next中,Image组件通过绑定PixelMap状态变量实现自动更新。将PixelMap定义为@State属性,更新时直接赋值新的PixelMap对象,UI会自动重绘。也可调用ImageController的updatePixelMap()方法手动触发刷新。无需其他操作。
您当前“双 PixelMap 切换”方案是合理且高效的,因为 Image 组件依赖于对象引用的变化来触发刷新,直接修改 PixelMap 内容不会引起状态更新。这是 ArkUI 状态管理的特性决定的。
若想简化,可直接将收到的数据构造为新的 PixelMap 并赋值给 @State 变量,但频繁创建/销毁对象有 GC 开销。您的方案避免了重复创建,属于优化做法,没有更“原生”的原地刷新 API。


