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,不知道有没有更好的解决方案呢?

10 回复

开发者您好,调用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的社区里有很多技术大牛分享经验,学到了很多有用的知识。

  1. rgba8888也就是4个8位存储图片像素信息,所以64x64的小图大概646432/8个字节,再加上图片其他信息算大概20K左右吧。图片很小,不用双buffer(你的双PixelMap),另外服务器也不会把20K的图片拆开分次发。
  2. 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。

PixelMap 在 writeBufferToPixelsSync 之后,不会自动触发 Image 刷新。原因不是写入失败,而是 ArkUI 的刷新是由状态变化驱动的;只有被装饰的状态变量发生变化,才会触发重渲染。你这个应该是“原地改写同一个 PixelMap 的像素内容”,这不等价于“Image 绑定的数据源引用变了”,所以 Image 往往不会更新。

建议使用以下方式试试:

  1. 页面里用一个 @Local 的 currentPixelMap 作为 Image 的数据源。
  2. 同时维护 frontBuffer 和 backBuffer 两个 PixelMap。
  3. 新帧到来时,只给 backBuffer 写入。
  4. 写完后把 currentPixelMap 切到 backBuffer,再交换 frontBuffer 和 backBuffer。

@State声明不是自动刷新UI吗

在HarmonyOS Next中,Image组件通过绑定PixelMap状态变量实现自动更新。将PixelMap定义为@State属性,更新时直接赋值新的PixelMap对象,UI会自动重绘。也可调用ImageController的updatePixelMap()方法手动触发刷新。无需其他操作。

您当前“双 PixelMap 切换”方案是合理且高效的,因为 Image 组件依赖于对象引用的变化来触发刷新,直接修改 PixelMap 内容不会引起状态更新。这是 ArkUI 状态管理的特性决定的。

若想简化,可直接将收到的数据构造为新的 PixelMap 并赋值给 @State 变量,但频繁创建/销毁对象有 GC 开销。您的方案避免了重复创建,属于优化做法,没有更“原生”的原地刷新 API。

回到顶部