图片/视频操作一条龙(1):图片的获取,预览与压缩(鸿蒙开发)

图片/视频操作一条龙(1):图片的获取,预览与压缩(鸿蒙开发)

📖 文章目录

文章有点小长,所以先放个目录,方便大家对文章的总体结构有所了解

🎯 主要内容

  • 参考文档 - 相关开发文档链接
  • 操作步骤讲解
    • 🔐 声明权限 - 配置媒体访问所需权限
    • ✅ 检查/获取权限 - 运行时权限申请与管理
    • 🖼️ 选取图片 - 两种图片选择方法及其对比
      • 🎯 从相册选择媒体(系统选择器)
      • 🔄 另一种选取图片的方法(直接访问媒体库)
      • ⚖️ 两种方法的区别与使用场景
    • 🗜️ 图片压缩并存到沙箱路径
      • ⚠️ 重要说明(沙箱路径限制)
      • 🚫 获取文件大小的注意事项
      • ✅ 正确的文件大小获取方法
      • 🔧 代码实现
  • 完整代码实现与使用
    • 🛠️ ImageOperationUtil 工具类 - 完整的图片操作工具封装
    • 🖼️ MediaPreviewDialog 自定义组件 - 全屏预览组件实现
    • 🎯 在组件中使用 - 实际使用示例

说明:虽然这这篇文章中重点是讲解图片操作,但是封装的函数都考虑到了视频的需求,对不同类型媒体文件(Image/Video)的操作在必要处做了区分,不过当前视频部分的处理还不够完善,比如对于图片有考虑是否进行压缩,视频目前仅是统一复制到沙箱中,下一期我们会来聊一聊视频转码,首帧获取等操作并完善视频部分。


📚 参考文档


1.操作详解

🔐 1.1 声明权限

module.json5 文件中声明所需权限(核心在于 requestPermissions 中的设置):

json 复制代码
{
  "module": {
    "name": "community", //大家自行替换
    "type": "shared",
    "description": "$string:shared_desc",
    "deviceTypes": [
      "phone",
      "tablet",
      "2in1"
    ],
    "requestPermissions": [
      { "name": "ohos.permission.INTERNET" },//网络权限,用于后续上传图片到服务端或存储端
      { 
        "name": "ohos.permission.CAMERA",//相机权限--用于拍摄等
        "reason": "$string:camera_permission_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.READ_MEDIA",//读取媒体库的权限
        "reason": "$string:read_media_permission_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.WRITE_MEDIA",//写入媒体库的权限
        "reason": "$string:write_media_permission_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        },
      }
    ],
    "deliveryWithInstall": true,
    "pages": "$profile:main_pages",
    "routerMap": "$profile:router_map"
  }
}

✅ 1.2 检查/获取权限

typescript 复制代码
import { abilityAccessCtrl, PermissionRequestResult, Permissions } from '@kit.AbilityKit';

private async checkAndRequestCameraPermission(): Promise<boolean> {
  try {
    const atManager = abilityAccessCtrl.createAtManager()
    const context = getContext(this) as common.UIAbilityContext;
    
    // ⚠️ API ≥ 18 时需要改为使用以下方式获取 context
    // const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
    
    // 需要的权限
    const permissions: Permissions[] = ['ohos.permission.CAMERA','ohos.permission.READ_MEDIA','ohos.permission.WRITE_MEDIA']

    // 检查权限
    for (const permission of permissions) {
      const grantStatus = await atManager.checkAccessToken(context.applicationInfo.accessTokenId, permission)
      if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
        // 请求权限
        const result: PermissionRequestResult = await atManager.requestPermissionsFromUser(context, permissions)
        return result.authResults.every(authResult => authResult === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED)
      }
    }

    return true
  } catch (error) {
    console.error('权限检查失败:', error)
    return false
  }
}

💡 既然这里提到getContext()被废弃了,那么我们这里多唠一嘴 --关于 getContext() 被废弃的说明

kotlin 复制代码
    const context = getContext(this) as common.UIAbilityContext;
    //api≥18改为使用:
    //const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
    // 需要的权限

为什么被废除?

getContext() / getContext(this) 属于早期(API 9~17)兼容方案,内部通过调用栈回溯去找上下文,容易在异步场景或组件复用场景下拿到错误的 Context,因此从 API 18 起官方正式废弃。

1️⃣ this.getUIContext() 是干嘛的?

背景: 在 Stage 模型里,一个应用可以:

  • 同时存在多个 Ability(UIAbility、ExtensionAbility ...)
  • 每个 Ability 又能开多个 Window
  • 每个 Window 又会通过 loadContent() 生成一个独立的 ArkUI 实例

因此 "我到底在哪一个 ArkUI 实例里?" 必须先搞清楚,否则弹 Toast、开弹窗、拿资源都可能搞错窗口。

作用: this.getUIContext() 返回一个 UIContext 对象,它就是你当前组件所挂载的那个 ArkUI 实例的"把手"。

  • 只有拿到这个把手,框架才知道后续所有 UI 操作(Toast、Dialog、Router...)应该落在哪个窗口
  • 同时也只有这个把手才能再往下拿到真正的系统 Context

典型场景: 在异步回调里想弹 Toast:

typescript 复制代码
   let uiCtx = this.getUIContext();        // 1. 先锁定当前 UI 实例
   let prompt = uiCtx.getPromptAction();   // 2. 再取该实例专属的弹窗管理器
   prompt.showToast({ message: 'ok' });

如果直接写全局 promptAction.showToast(),在多窗口场景下可能把 Toast 弹到别的窗口去。

2️⃣ getHostContext() 什么用?

一句话总结(最近一些简单的问答用kimi比较多,学一学kimi的口头禅哈哈): uiContext.getHostContext() 就是把"当前 ArkUI 实例"背后那个真正的 AbilityContext(通常是 UIAbilityContext) 取出来,让组件能够继续访问文件、数据库、权限、拉起 Service 等系统能力。

详细说明:

  • 返回值:拿到的是 Ability 层真正的 Context(UIAbilityContext / ExtensionContext)
  • 用途:所有需要 Context 的系统 API(preferences、resourceManager、startAbility、requestPermissions...)都必须用它
  • 时机:仅在 ArkUI 层(组件内部)需要访问系统资源时才调用;纯 UI 逻辑(布局、事件)不需要它

🖼️ 1.3 选取图片(可限制单次最大选取数)

📋 定义数据接口

首先定义对应的接口便于管理数据:

typescript 复制代码
export enum MediaType {
  IMAGE = 'image',
  VIDEO = 'video'
}

//媒体文件接口
export interface MediaFile {
  uri: string; //原始路径,用于预览
  type: MediaType; //方便后续加入视频,便于拓展
  name: string;
  isRemote?: boolean; // 标识是否为远程文件(已上传到服务器的文件),主要用于做diff:比如修改一个帖子时不在上传已有公网可访问地址的媒体文件
  sandboxPath?: string; // 沙箱中的真实文件路径,用于上传
}

🎯 从相册选择媒体

typescript 复制代码
import { photoAccessHelper } from '@kit.MediaLibraryKit';

// 从相册选择媒体
const maxSelectPhotoNumber: number = 9; // 设置最大可选择数
const maxSelectVideoNumber: number = 3;
static async pickMedia(
    type: MediaType,
    context: common.UIAbilityContext,
    mediaFiles: MediaFile[],
    onMediaSelected: (mediaFile: MediaFile) => void,
    showToast: (message: string) => void
  ): Promise<void> {
    try {
      // 计算剩余可选择数量(基于总文件数量)
      const totalCount = mediaFiles.filter(file => file.type === type).length;
      const maxCount = type === MediaType.IMAGE ? maxSelectPhotoNumber : maxSelectVideoNumber;
      const remainingCount = maxCount - totalCount;

      if (remainingCount <= 0) {
        showToast(`最多只能添加${maxCount}个${type === MediaType.IMAGE ? '图片' : '视频'}`);
        return;
      }

      const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
      photoSelectOptions.MIMEType = type === MediaType.IMAGE
        ? photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE
        : photoAccessHelper.PhotoViewMIMETypes.VIDEO_TYPE;
      photoSelectOptions.maxSelectNumber = remainingCount;

      const photoPicker = new photoAccessHelper.PhotoViewPicker();
      const result: photoAccessHelper.PhotoSelectResult =
        await photoPicker.select(photoSelectOptions); //结果存在的属性:photoUris,isOriginalPhoto

      if (result && result.photoUris && result.photoUris.length > 0) {
        for (let index = 0; index < result.photoUris.length; index++) {
          const uri = result.photoUris[index];
          try {
            // 根据类型选择处理方式
            const sandboxPath = type === MediaType.IMAGE
              ? await ImageOperationUtil.compressAndCopyImage(uri, context)
              : ImageOperationUtil.copyToCache(uri, context); //compressAndCopyImage和copyToCache都为单独封装的方法,这里视频处理暂时统一采用了直接copyToCache,下一期我们聊聊视频转码并完善视频相关的操作

            // 从URI中提取文件扩展名
            const getFileExtension = (uri: string): string => {
              const extension = uri.toLowerCase().split('.').pop();
              if (type === MediaType.IMAGE) {
                return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(extension || '') ? extension! : 'jpg';
              } else {
                return ['mp4', 'avi', 'mov', 'wmv', '3gp', 'mkv'].includes(extension || '') ? extension! : 'mp4';
              }
            };

            const mediaFile: MediaFile = {
              uri: uri, // 原始安全URI,用于预览
              type: type,
              name: `gallery_${type === MediaType.IMAGE ? 'photo' :
                'video'}_${Date.now()}_${index}.${getFileExtension(uri)}`,
              isRemote: false, // 标识为本地文件
              sandboxPath: sandboxPath // 沙箱路径,用于上传
            };
            onMediaSelected(mediaFile);
          } catch (error) {
            console.error(`文件拷贝失败 ${uri}:`, error);
            showToast(`文件处理失败: ${uri}`);
          }
        }

        showToast(type === MediaType.IMAGE ? '选择图片成功' : '选择视频成功');
      }
    } catch (error) {
      console.error('选择文件失败:', error);
      showToast('选择文件失败');
    }
  }

🔄 另一种选取图片的方法

除了上述方法,还可以直接从媒体库加载图片:

typescript 复制代码
  // 从相册加载图片(直接获取媒体库资源)
  static async loadImagesFromAlbum(
    context: common.UIAbilityContext, 
    onMediaSelected: (mediaFile: MediaFile) => void,
    showToast?: (message: string) => void
  ): Promise<void> {
    try {
      // 检查媒体库权限
      const atManager = abilityAccessCtrl.createAtManager();
      const grantStatus = await atManager.checkAccessToken(
        context.applicationInfo.accessTokenId,
        'ohos.permission.READ_MEDIA'
      );
      
      if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
        const result: PermissionRequestResult = await atManager.requestPermissionsFromUser(
          context, 
          ['ohos.permission.READ_MEDIA']
        );
        if (result.authResults[0] !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
          showToast?.('需要媒体库访问权限');
          return;
        }
      }

      // 创建获取选项
      const fetchOptions: photoAccessHelper.FetchOptions = {
        fetchColumns: [
          photoAccessHelper.PhotoKeys.URI,
          photoAccessHelper.PhotoKeys.DISPLAY_NAME,
          photoAccessHelper.PhotoKeys.SIZE,
          photoAccessHelper.PhotoKeys.DATE_ADDED
        ],
        predicates: new dataSharePredicates.DataSharePredicates()
      };

      // 只获取图片类型,按时间倒序排列
      fetchOptions.predicates?.equalTo(photoAccessHelper.PhotoKeys.PHOTO_TYPE, photoAccessHelper.PhotoType.IMAGE);
      fetchOptions.predicates?.orderByDesc(photoAccessHelper.PhotoKeys.DATE_ADDED);

      // 获取图片资源
      const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
      const fetchResult = await phAccessHelper.getAssets(fetchOptions);
      const photoAssets = await fetchResult.getAllObjects();

      console.log(`找到 ${photoAssets.length} 张图片`);
      showToast?.(`找到 ${photoAssets.length} 张图片`);

      // 处理图片数据(限制处理数量避免性能问题)
      const processCount = Math.min(photoAssets.length, 10);
      for (let i = 0; i < processCount; i++) {
        const asset = photoAssets[i];
        try {
          // 创建 MediaFile 对象
          const mediaFile: MediaFile = {
            uri: asset.uri,
            type: MediaType.IMAGE,
            name: asset.displayName || `相册图片_${i + 1}.jpg`,
            isRemote: false
          };
          
          // 调用回调函数
          onMediaSelected(mediaFile);
          console.log(`已添加图片: ${mediaFile.name}`);
          
        } catch (error) {
          console.error(`处理图片失败 [${i}]:`, error);
        }
      }
      
      // 关闭资源
      fetchResult.close();
      
    } catch (error) {
      console.error('加载相册图片失败:', error);
      showToast?.('加载相册图片失败');
    }
  }

⚖️ 两种方法的区别

1. 使用的API不同

第一种方式:

  • 使用 photoAccessHelper.PhotoViewPicker() 创建选择器
  • 使用 PhotoSelectOptions 配置选择参数
  • 调用 select() 方法打开系统选择界面

第二种方式:

  • 使用 photoAccessHelper.getPhotoAccessHelper() 获取访问助手
  • 使用 getAssets() 方法获取媒体资源
  • 通过 FetchOptionsDataSharePredicates 进行查询配置
2. 用户交互方式不同

第一种方式:

  • 调用系统提供的标准媒体选择界面
  • 用户在系统原生的选择器中进行选择
  • 界面样式由系统决定,保持系统一致性
  • 实现更简单,但定制性较低

第二种方式:

  • 直接获取媒体库中的所有资源数据
  • 需要开发者自己构建UI界面来展示和选择
  • 可以完全自定义界面样式和交互逻辑
  • 用户在应用内的自定义界面中选择
3. 数据获取方式不同

第一种方式:

  • 直接获取用户选择的文件URI
  • 主要关注用户的选择结果
  • 适合简单的文件选择场景

第二种方式:

  • 获取媒体文件的详细信息(URI、显示名称、大小、添加时间等)
  • 可以进行复杂的筛选和排序(如按时间倒序)
  • 适合需要展示媒体库详细信息的场景
4. 使用场景建议
  • 第一种方式适合简单的文件选择需求,希望使用系统标准界面,快速实现选择功能的场景
  • 第二种方式适合需要自定义媒体浏览界面、需要显示媒体详细信息、或需要复杂筛选功能的场景,也可以用于对图片的批量处理,因为可以直接加载相册中的所有图片

🗜️ 1.4 图片压缩并存到沙箱路径

⚠️ 注意

相册/相机返回的 原始 uri 属于系统沙箱之外的受保护资源 ,鸿蒙 request.uploadFile() 只能上传应用沙箱里的文件,所以 预览可以,上传不行

解决方案: 先把图片 拷贝到自己的 cacheDir,再用新路径上传。

🚫 获取文件大小的注意事项

不要使用:

typescript 复制代码
const stat = fs.lstatSync(uri);
fileSizeMB = stat.size / (1024 * 1024);

原因:

在鸿蒙(OpenHarmony)里:

  • 从相册/相机返回的 URI 通常是:

    ruby 复制代码
    dataability:///media/external/images/media/12345

    这类 dataability://media://虚拟 URI并不是真正的文件路径

  • fs.lstatSync(uri) 只能处理 file:// 开头的 真实文件路径 ,遇到 dataability:// 会直接抛异常(ENOENT 或 URI not supported)

✅ 如何正确获取文件大小--通过文件描述符获取

asset.size 在鸿蒙 4.0+ 已提供,无需再 open 文件。

如果拿到的是纯 URI,则先 拿到 fd ,再 fs.stat(fd)

typescript 复制代码
   import fs from '@ohos.file.fs';
   
   const fd = fs.openSync(uri, fs.OpenMode.READ_ONLY);
   const stat = fs.stat(fd);
   const fileSizeMB = stat.size / 1024 / 1024;
   fs.closeSync(fd);

🔧 压缩与沙箱存储的实现代码

typescript 复制代码
import fs from '@ohos.file.fs';
import image from '@ohos.multimedia.image';
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';

  
  static async compressAndCopyImage(uri: string, context: common.UIAbilityContext): Promise<string> {
    try {
      // 获取文件大小并打开文件
      let srcFd: number | null = null;
      let fileSizeMB: number | null = null;

      try {
        if (uri.includes('://')) {
          const srcFile = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
          srcFd = srcFile.fd;
          const stat = fs.statSync(srcFd);
          fileSizeMB = stat.size / (1024 * 1024);
        } else {
          const srcFile = fs.openSync(uri, fs.OpenMode.READ_ONLY);
          srcFd = srcFile.fd;
          const stat = fs.statSync(srcFd);
          fileSizeMB = stat.size / (1024 * 1024);
        }
      } catch (e) {
        console.warn('获取文件大小失败,忽略并继续压缩:', e);
      }

      if (fileSizeMB !== null && fileSizeMB < 1) {
        console.log(`图片文件较小(${fileSizeMB.toFixed(2)}MB),直接拷贝无需压缩`);
        // 关闭已打开的文件描述符
        if (srcFd !== null) {
          try {
            fs.closeSync(srcFd);
          } catch (error) {
            console.warn('关闭文件描述符失败:', error);
          }
        }
        return ImageOperationUtil.copyToCache(uri, context);
      }

      console.log('开始图片压缩处理(使用文件描述符创建 ImageSource,兼容 file://media URI)');

      let imageSource: image.ImageSource | null = null;
      try {
        // 如果之前没有成功打开文件,这里重新尝试
        if (srcFd === null) {
          if (uri.includes('://')) {
            const srcFile = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
            srcFd = srcFile.fd;
          } else {
            const srcFile = fs.openSync(uri, fs.OpenMode.READ_ONLY);
            srcFd = srcFile.fd;
          }
        }
        imageSource = image.createImageSource(srcFd);
      } catch (openErr) {
        console.warn('通过fd打开媒体失败,尝试使用字符串路径创建 ImageSource(仅限本地路径):', openErr);
        if (!uri.includes('://')) {
          try {
            imageSource = image.createImageSource(uri);
          } catch (pathErr) {
            console.error('字符串路径创建 ImageSource 失败:', pathErr);
          }
        }
      }

      if (!imageSource) {
        throw new Error('创建 ImageSource 失败');
      }

      const imageInfo = await imageSource.getImageInfo();
      const pixelMap = await imageSource.createPixelMap();

      if (imageInfo.size.width > 1280 || imageInfo.size.height > 1280) {
        const scale = Math.min(1280 / imageInfo.size.width, 1280 / imageInfo.size.height);
        await pixelMap.scale(scale, scale);
        console.log(`图片尺寸缩放: ${imageInfo.size.width}x${imageInfo.size.height} -> ${Math.floor(imageInfo.size.width *
          scale)}x${Math.floor(imageInfo.size.height * scale)}`);
      }

      const imagePacker = image.createImagePacker();
      const pixelCount = imageInfo.size.width * imageInfo.size.height;
      let quality: number = 75;
      if (pixelCount > 12_000_000) {
        quality = 50;
      } else if (pixelCount > 6_000_000) {
        quality = 60;
      }

      let packOpts: image.PackingOption = { format: 'image/webp', quality: quality };
      let compressedData: ArrayBuffer;
      try {
        compressedData = await imagePacker.packing(pixelMap, packOpts);
      } catch (e) {
        console.warn('WebP导出失败,回退到JPEG:', e);
        packOpts = { format: 'image/jpeg', quality: quality };
        compressedData = await imagePacker.packing(pixelMap, packOpts);
      }
      const targetExt: string = packOpts.format === 'image/webp' ? '.webp' : '.jpg';

      // 压缩后直接写入沙箱
      const fileName = uri.split('/').pop() || `compressed_${Date.now()}.jpg`;
      const compressedFileName = `compressed_${Date.now()}_${fileName.replace(/\.[^.]+$/, targetExt)}`;
      const dstPath = context.cacheDir + '/' + compressedFileName;

      const dst = fs.openSync(dstPath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
      fs.writeSync(dst.fd, compressedData); // 直接写入 ArrayBuffer
      fs.closeSync(dst);

      pixelMap.release();
      imagePacker.release();
      imageSource.release();
      if (srcFd !== null) {
        try {
          fs.closeSync(srcFd);
        } catch (error) {
          console.warn('关闭文件描述符失败:', error);
        }
      }

      const compressedSizeMB = compressedData.byteLength / (1024 * 1024);
      console.log(`图片压缩完成: ${fileName} (约 ${compressedSizeMB.toFixed(2)}MB, 格式${packOpts.format}, 质量${quality}%)`);
      return dstPath;
    } catch (error) {
      console.error('图片压缩失败,使用原文件:', error);
      return ImageOperationUtil.copyToCache(uri, context);
    }
  }

  // 将文件拷贝到沙箱并返回真实路径(用于视频等非图片文件)
  static copyToCache(uri: string, context: common.UIAbilityContext): string {
    try {
      let srcFd: number;
      if (uri.includes('://')) {
        const src = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
        srcFd = src.fd;
      } else {
        const src = fs.openSync(uri, fs.OpenMode.READ_ONLY);
        srcFd = src.fd;
      }

      const fileName = uri.split('/').pop() || `file_${Date.now()}`;
      const dstPath = context.cacheDir + '/' + Date.now() + '_' + fileName;
      const dst = fs.openSync(dstPath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);

      let len = 0;
      const buf = new ArrayBuffer(8192);
      while ((len = fs.readSync(srcFd, buf)) > 0) {
        fs.writeSync(dst.fd, buf.slice(0, len));
      }
      fs.closeSync(srcFd);
      fs.closeSync(dst);
      return dstPath;
    } catch (error) {
      console.error('文件拷贝失败:', error);
      throw error as Error;
    }
 }

👁️ 1.5 全屏预览图片组件(和非全屏预览核心相同,都是利用Image(uri)组件而已,只是样式稍作补充,同时使用了CustomDialogController)

构建全屏预览组件

typescript 复制代码
import { MediaFile, MediaType } from 'basic/src/main/ets/models/mediaFile'

@CustomDialog
export struct MediaPreviewDialog {
  mediaFile: MediaFile | null = null
  controller: CustomDialogController  //CustomDialogController的作用:Display the content of the customized pop-up window,含有constructor,open和close方法

  build() {
    Stack({ alignContent: Alignment.TopStart }) {
      // 媒体内容区域(全屏背景)
      if (this.mediaFile) {
        if (this.mediaFile.type === MediaType.IMAGE) {
          // 图片预览
          Image(this.mediaFile.uri)
            .width('100%')
            .height('100%')
            .objectFit(ImageFit.Contain)
            .backgroundColor('#000000')
        } else if (this.mediaFile.type === MediaType.VIDEO) {
          // 视频预览
          Video({
            src: this.mediaFile.uri,
            previewUri: this.mediaFile.uri
          })
            .width('100%')
            .height('100%')
            .controls(true)
            .autoPlay(false)
            .objectFit(ImageFit.Contain)
            .backgroundColor('#000000')
        }
      } else {
        // 无媒体文件时的占位内容
        Column() {
          Text('无媒体文件')
            .fontSize(16)
            .fontColor('#999999')
            .textAlign(TextAlign.Center)
        }
        .width('100%')
        .height('100%')
        .backgroundColor('#000000')
        .justifyContent(FlexAlign.Center)
      }
      
      // 左上角回退按钮
      Row() {
        Image($r('app.media.back'))
          .width(24)
          .height(24)
          .fillColor(Color.White)
          .onClick(() => {
            this.controller.close()
          })
      }
      .width(44)
      .height(44)
      .justifyContent(FlexAlign.Center)
      .alignItems(VerticalAlign.Center)
      .backgroundColor('rgba(0, 0, 0, 0.3)')
      .borderRadius(22)
      .margin({ left: 20, top: 20 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#000000')
  }

  // 设置要预览的媒体文件
  setMediaFile(mediaFile: MediaFile): void {
    this.mediaFile = mediaFile
  }
}

2. 完整代码示例


🛠️ 2.1 ImageOperationUtil 工具类

📋 完整代码实现

typescript 复制代码
import fs from '@ohos.file.fs';
import image from '@ohos.multimedia.image';
import { fileIo } from '@kit.CoreFileKit';
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
import { MediaType, MediaFile } from '../models/mediaFile';
import { promptAction } from '@kit.ArkUI';
import { camera, cameraPicker as picker } from '@kit.CameraKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { dataSharePredicates } from '@kit.ArkData';

export class ImageOperationUtil {
  // 选择媒体文件
  static async selectMedia(
    type: MediaType,
    context: common.UIAbilityContext,
    mediaFiles: MediaFile[],
    onMediaSelected: (mediaFile: MediaFile) => void,
    showToast: (message: string) => void,
    showActionMenu: (options: promptAction.ActionMenuOptions) => Promise<promptAction.ActionMenuSuccessResponse>
  ): Promise<void> {
    // 检查文件数量限制(只计算本地文件,远程文件不计入限制)
    const totalCount = mediaFiles.filter(file => file.type === type).length
    const maxCount = type === MediaType.IMAGE ? 9 : 3

    if (totalCount >= maxCount) {
      showToast(`最多只能添加${maxCount}个${type === MediaType.IMAGE ? '图片' : '视频'}`);
      return
    }

    const actionMenuOptions: promptAction.ActionMenuOptions = {
      title: type === MediaType.IMAGE ? '选择图片' : '选择视频',
      buttons: [
        { text: '使用相机拍摄', color: '#007AFF' },
        { text: '从相册中选择', color: '#007AFF' }
      ]
    };
    const result = await showActionMenu(actionMenuOptions);

    if (result.index === 0) {
      // 后置相机拍摄
      await ImageOperationUtil.captureMediaWithCamera(
        type,
        camera.CameraPosition.CAMERA_POSITION_BACK,
        context,
        onMediaSelected,
        showToast
      );
    } else if (result.index === 1) {
      // 从相册选择
      await ImageOperationUtil.pickMedia(
        type,
        context,
        mediaFiles,
        onMediaSelected,
        showToast
      );
    }
  }

  // 使用指定摄像头拍摄媒体
  static async captureMediaWithCamera(
    type: MediaType,
    cameraPosition: camera.CameraPosition,
    context: common.UIAbilityContext,
    onMediaSelected: (mediaFile: MediaFile) => void,
    showToast: (message: string) => void
  ): Promise<void> {
    try {
      // 检查并请求相机权限
      const hasPermission = await ImageOperationUtil.checkAndRequestCameraPermission(context);
      if (!hasPermission) {
        showToast('应用需要相机权限');
        return;
      }

      // 配置相机选择器
      const pickerProfile: picker.PickerProfile = {
        cameraPosition: cameraPosition
      };

      // 如果是视频录制,设置最大录制时长(30秒)
      if (type === MediaType.VIDEO) {
        pickerProfile.videoDuration = 30;
      }

      // 根据类型设置媒体类型
      const mediaTypes: picker.PickerMediaType[] = type === MediaType.IMAGE
        ? [picker.PickerMediaType.PHOTO]
        : [picker.PickerMediaType.VIDEO];

      // 调用相机选择器
      const pickerResult: picker.PickerResult = await picker.pick(context, mediaTypes, pickerProfile);

      console.log("cameraPicker result:", JSON.stringify(pickerResult));

      if (pickerResult.resultCode === 0 && pickerResult.resultUri) {
        try {
          // 根据类型选择处理方式
          const sandboxPath = type === MediaType.IMAGE
            ? await ImageOperationUtil.compressAndCopyImage(pickerResult.resultUri, context)
            : ImageOperationUtil.copyToCache(pickerResult.resultUri, context);

          // 创建媒体文件对象
          const mediaFile: MediaFile = {
            uri: pickerResult.resultUri, // 原始URI,用于预览
            type: type,
            name: type === MediaType.IMAGE ? `camera_photo_${Date.now()}.jpg` : `camera_video_${Date.now()}.mp4`,
            isRemote: false, // 标识为本地文件
            sandboxPath: sandboxPath // 沙箱路径,用于上传
          };

          onMediaSelected(mediaFile);

          showToast(type === MediaType.IMAGE ? '拍照成功' : '录制成功');
        } catch (error) {
          console.error('文件处理失败:', error);
          showToast('文件处理失败,请重试');
        }
      } else {
        showToast('拍摄取消');
      }
    } catch (error) {
      console.error('拍摄失败:', error);
      const err = error as BusinessError;
      showToast(`拍摄失败: ${err.message || '未知错误'}`);
    }
  }

  // 检查并请求相机权限
  static async checkAndRequestCameraPermission(context: common.UIAbilityContext): Promise<boolean> {
    try {
      const atManager = abilityAccessCtrl.createAtManager();

      // 需要的权限
      const permissions: Permissions[] = ['ohos.permission.CAMERA', 'ohos.permission.MICROPHONE'];

      // 检查权限
      for (const permission of permissions) {
        const grantStatus = await atManager.checkAccessToken(
          context.applicationInfo.accessTokenId,
          permission
        );
        if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
          // 请求权限
          const result: PermissionRequestResult = await atManager.requestPermissionsFromUser(context, permissions);
          return result.authResults.every(authResult => authResult ===
          abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED);
        }
      }

      return true;
    } catch (error) {
      console.error('权限检查失败:', error);
      return false;
    }
  }

  // 从相册选择媒体
  static async pickMedia(
    type: MediaType,
    context: common.UIAbilityContext,
    mediaFiles: MediaFile[],
    onMediaSelected: (mediaFile: MediaFile) => void,
    showToast: (message: string) => void
  ): Promise<void> {
    try {
      // 计算剩余可选择数量(基于总文件数量)
      const totalCount = mediaFiles.filter(file => file.type === type).length;
      const maxCount = type === MediaType.IMAGE ? 9 : 3;
      const remainingCount = maxCount - totalCount;

      if (remainingCount <= 0) {
        showToast(`最多只能添加${maxCount}个${type === MediaType.IMAGE ? '图片' : '视频'}`);
        return;
      }

      const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
      photoSelectOptions.MIMEType = type === MediaType.IMAGE
        ? photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE
        : photoAccessHelper.PhotoViewMIMETypes.VIDEO_TYPE;
      photoSelectOptions.maxSelectNumber = remainingCount;

      const photoPicker = new photoAccessHelper.PhotoViewPicker();
      const result: photoAccessHelper.PhotoSelectResult = await photoPicker.select(photoSelectOptions);

      if (result && result.photoUris && result.photoUris.length > 0) {
        for (let index = 0; index < result.photoUris.length; index++) {
          const uri = result.photoUris[index];
          try {
            // 根据类型选择处理方式
            const sandboxPath = type === MediaType.IMAGE
              ? await ImageOperationUtil.compressAndCopyImage(uri, context)
              : ImageOperationUtil.copyToCache(uri, context);//目前视频暂时只是统一存储到沙箱中而没有做额外处理,下一期我们来聊一聊视频转码,首帧获取等操作并完善视频部分

            // 从URI中提取文件扩展名
            const getFileExtension = (uri: string): string => {
              const extension = uri.toLowerCase().split('.').pop();
              if (type === MediaType.IMAGE) {
                return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(extension || '') ? extension! : 'jpg';
              } else {
                return ['mp4', 'avi', 'mov', 'wmv', '3gp', 'mkv'].includes(extension || '') ? extension! : 'mp4';
              }
            };

            const mediaFile: MediaFile = {
              uri: uri, // 原始安全URI,用于预览
              type: type,
              name: `gallery_${type === MediaType.IMAGE ? 'photo' :
                'video'}_${Date.now()}_${index}.${getFileExtension(uri)}`,
              isRemote: false, // 标识为本地文件
              sandboxPath: sandboxPath // 沙箱路径,用于上传
            };
            onMediaSelected(mediaFile);
          } catch (error) {
            console.error(`文件拷贝失败 ${uri}:`, error);
            showToast(`文件处理失败: ${uri}`);
          }
        }

        showToast(type === MediaType.IMAGE ? '选择图片成功' : '选择视频成功');
      }
    } catch (error) {
      console.error('选择文件失败:', error);
      showToast('选择文件失败');
    }
  }

  // 压缩拷贝到沙箱路径
  static async compressAndCopyImage(uri: string, context: common.UIAbilityContext): Promise<string> {
    try {
      // 获取文件大小并打开文件
      let srcFd: number | null = null;
      let fileSizeMB: number | null = null;

      try {
        if (uri.includes('://')) {
          const srcFile = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
          srcFd = srcFile.fd;
          const stat = fs.statSync(srcFd);
          fileSizeMB = stat.size / (1024 * 1024);
        } else {
          const srcFile = fs.openSync(uri, fs.OpenMode.READ_ONLY);
          srcFd = srcFile.fd;
          const stat = fs.statSync(srcFd);
          fileSizeMB = stat.size / (1024 * 1024);
        }
      } catch (e) {
        console.warn('获取文件大小失败,忽略并继续压缩:', e);
      }

      if (fileSizeMB !== null && fileSizeMB < 1) {
        console.log(`图片文件较小(${fileSizeMB.toFixed(2)}MB),直接拷贝无需压缩`);
        // 关闭已打开的文件描述符
        if (srcFd !== null) {
          try {
            fs.closeSync(srcFd);
          } catch (_) {
          }
        }
        return ImageOperationUtil.copyToCache(uri, context);
      }

      console.log('开始图片压缩处理(使用文件描述符创建 ImageSource,兼容 file://media URI)');

      let imageSource: image.ImageSource | null = null;
      try {
        // 如果之前没有成功打开文件,这里重新尝试
        if (srcFd === null) {
          if (uri.includes('://')) {
            const srcFile = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
            srcFd = srcFile.fd;
          } else {
            const srcFile = fs.openSync(uri, fs.OpenMode.READ_ONLY);
            srcFd = srcFile.fd;
          }
        }
        imageSource = image.createImageSource(srcFd);
      } catch (openErr) {
        console.warn('通过fd打开媒体失败,尝试使用字符串路径创建 ImageSource(仅限本地路径):', openErr);
        if (!uri.includes('://')) {
          try {
            imageSource = image.createImageSource(uri);
          } catch (pathErr) {
            console.error('字符串路径创建 ImageSource 失败:', pathErr);
          }
        }
      }

      if (!imageSource) {
        throw new Error('创建 ImageSource 失败');
      }

      const imageInfo = await imageSource.getImageInfo();
      const pixelMap = await imageSource.createPixelMap();

      if (imageInfo.size.width > 1280 || imageInfo.size.height > 1280) {
        const scale = Math.min(1280 / imageInfo.size.width, 1280 / imageInfo.size.height);
        await pixelMap.scale(scale, scale);
        console.log(`图片尺寸缩放: ${imageInfo.size.width}x${imageInfo.size.height} -> ${Math.floor(imageInfo.size.width *
          scale)}x${Math.floor(imageInfo.size.height * scale)}`);
      }

      const imagePacker = image.createImagePacker();
      const pixelCount = imageInfo.size.width * imageInfo.size.height;
      let quality: number = 75;
      if (pixelCount > 12_000_000) {
        quality = 50;
      } else if (pixelCount > 6_000_000) {
        quality = 60;
      }

      let packOpts: image.PackingOption = { format: 'image/webp', quality: quality };
      let compressedData: ArrayBuffer;
      try {
        compressedData = await imagePacker.packing(pixelMap, packOpts);
      } catch (e) {
        console.warn('WebP导出失败,回退到JPEG:', e);
        packOpts = { format: 'image/jpeg', quality: quality };
        compressedData = await imagePacker.packing(pixelMap, packOpts);
      }
      const targetExt: string = packOpts.format === 'image/webp' ? '.webp' : '.jpg';

      // 压缩后直接写入沙箱
      const fileName = uri.split('/').pop() || `compressed_${Date.now()}.jpg`;
      const compressedFileName = `compressed_${Date.now()}_${fileName.replace(/\.[^.]+$/, targetExt)}`;
      const dstPath = context.cacheDir + '/' + compressedFileName;

      const dst = fs.openSync(dstPath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
      fs.writeSync(dst.fd, compressedData); // 直接写入 ArrayBuffer
      fs.closeSync(dst);

      pixelMap.release();
      imagePacker.release();
      imageSource.release();
      if (srcFd !== null) {
        try {
          fs.closeSync(srcFd);
        } catch (_) {
        }
      }

      const compressedSizeMB = compressedData.byteLength / (1024 * 1024);
      console.log(`图片压缩完成: ${fileName} (约 ${compressedSizeMB.toFixed(2)}MB, 格式${packOpts.format}, 质量${quality}%)`);
      return dstPath;
    } catch (error) {
      console.error('图片压缩失败,使用原文件:', error);
      return ImageOperationUtil.copyToCache(uri, context);
    }
  }

  // 将文件拷贝到沙箱并返回真实路径
  static copyToCache(uri: string, context: common.UIAbilityContext): string {
    try {
      let srcFd: number;
      if (uri.includes('://')) {
        const src = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
        srcFd = src.fd;
      } else {
        const src = fs.openSync(uri, fs.OpenMode.READ_ONLY);
        srcFd = src.fd;
      }

      const fileName = uri.split('/').pop() || `file_${Date.now()}`;
      const dstPath = context.cacheDir + '/' + Date.now() + '_' + fileName;
      const dst = fs.openSync(dstPath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);

      let len = 0;
      const buf = new ArrayBuffer(8192);
      while ((len = fs.readSync(srcFd, buf)) > 0) {
        fs.writeSync(dst.fd, buf.slice(0, len));
      }
      fs.closeSync(srcFd);
      fs.closeSync(dst);
      return dstPath;
    } catch (error) {
      console.error('文件拷贝失败:', error);
      throw error as Error;
    }
  }
}

🖼️ 2.2 MediaPreviewDialog 自定义组件

📱 全屏预览组件实现

typescript 复制代码
import { MediaFile, MediaType } from 'basic/src/main/ets/models/mediaFile'

@CustomDialog
export struct MediaPreviewDialog {
  mediaFile: MediaFile | null = null
  controller: CustomDialogController

  build() {
    Stack({ alignContent: Alignment.TopStart }) {
      // 媒体内容区域(全屏背景)
      if (this.mediaFile) {
        if (this.mediaFile.type === MediaType.IMAGE) {
          // 图片预览
          Image(this.mediaFile.uri)
            .width('100%')
            .height('100%')
            .objectFit(ImageFit.Contain)
            .backgroundColor('#000000')
        } else if (this.mediaFile.type === MediaType.VIDEO) {
          // 视频预览
          Video({
            src: this.mediaFile.uri,
            previewUri: this.mediaFile.uri
          })
            .width('100%')
            .height('100%')
            .controls(true)
            .autoPlay(false)
            .objectFit(ImageFit.Contain)
            .backgroundColor('#000000')
        }
      } else {
        // 无媒体文件时的占位内容
        Column() {
          Text('无媒体文件')
            .fontSize(16)
            .fontColor('#999999')
            .textAlign(TextAlign.Center)
        }
        .width('100%')
        .height('100%')
        .backgroundColor('#000000')
        .justifyContent(FlexAlign.Center)
      }
      
      // 左上角回退按钮
      Row() {
        Image($r('app.media.back'))
          .width(24)
          .height(24)
          .fillColor(Color.White)
          .onClick(() => {
            this.controller.close()
          })
      }
      .width(44)
      .height(44)
      .justifyContent(FlexAlign.Center)
      .alignItems(VerticalAlign.Center)
      .backgroundColor('rgba(0, 0, 0, 0.3)')
      .borderRadius(22)
      .margin({ left: 20, top: 20 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#000000')
  }

  // 设置要预览的媒体文件
  setMediaFile(mediaFile: MediaFile): void {
    this.mediaFile = mediaFile
  }
}

🎯 2.3 在组件中使用

💡 使用示例

typescript 复制代码
import promptAction from '@ohos.promptAction';
import { common } from '@kit.AbilityKit';
import { MediaType, MediaFile } from 'basic/src/main/ets/models/mediaFile'
import { MediaPreviewDialog } from './MediaPreviewDialog'
import { ImageOperationUtil } from 'basic/src/main/ets/utils/ImageOperation';

@Component
export struct NewPost {
  @Link title: string
  @Link content: string
  @Link mediaFiles: MediaFile[]
  @Link wastedFiles: string [] //修改时通过在客户端明确删除掉的媒体文件免去在服务端重新做diff
  @Link isUploading: boolean
  @Link uploadProgress: number
  private context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
  // 媒体预览对话框控制器
  private mediaPreviewDialogController: CustomDialogController = new CustomDialogController({
    builder: MediaPreviewDialog({}),
    autoCancel: true,
    alignment: DialogAlignment.Center,
    customStyle: true
  }) //CustomDialogController的作用:Display the content of the customized pop-up window,含有constructor,open和close方法

  // 文本输入样式
  @Styles
  textInputStyle() {
    .backgroundColor(Color.White)
    .borderRadius(8)
    .padding(16)
  }

  // 构建媒体操作按钮
  @Builder
  buildMediaActionButtons() {
    Row() {
      // 添加图片按钮
      Row() {
        Image($r('app.media.image')) //替换为自己的图片资源
          .width(20)
          .height(20)

        Text('添加图片')
          .fontSize(15)
          .margin({ left: 8 })
          .fontColor('#666666')
      }
      .padding({
        left: 12,
        right: 12,
        top: 8,
        bottom: 8
      })
      .onClick(() => {
        this.selectMedia(MediaType.IMAGE)
      })

      Divider()
        .vertical(true)
        .height(20)
        .color('#E5E5E5')
        .margin({ left: 16, right: 16 })

      Row() {
        Image($r('app.media.video')) ////替换为自己的图片资源
          .width(20)
          .height(20)

        Text('添加视频')
          .fontSize(15)
          .fontColor('#666666')
          .margin({ left: 8 })
      }
      .padding({
        left: 12,
        right: 12,
        top: 8,
        bottom: 8
      })
      .onClick(() => {
        this.selectMedia(MediaType.VIDEO)
      })

      Divider()
        .vertical(true)
        .height(20)
        .color('#E5E5E5')
        .margin({ left: 16, right: 16 })
    }
    .width('100%')
    .height(44)
    .backgroundColor(Color.Transparent)
    .margin({ top: 8 })
    .justifyContent(FlexAlign.Start)
    .alignItems(VerticalAlign.Center)
  }

  // 构建媒体预览区域
  @Builder
  buildMediaPreview() {
    if (this.mediaFiles.length > 0) {
      Column() {
        Grid() {
          ForEach(this.mediaFiles,
            async (media: MediaFile, index: number) => {
              GridItem() {
                Stack({ alignContent: Alignment.TopEnd }) {
                  if (media.type === MediaType.IMAGE) {
                    Image(media.uri)
                      .width('100%')
                      .height('100%')
                      .objectFit(ImageFit.Cover)
                      .borderRadius(8)
                      .onClick(() => {
                        this.previewImage(media)
                      })
                  } else {
                    // 视频预览缩略图
                    Stack() {
                      Video({
                        src: media.uri,
                        previewUri: media.uri
                      })
                        .width('100%')
                        .height('100%')
                        .controls(false)
                        .autoPlay(false)
                        .borderRadius(8)

                      Image($r('app.media.play'))
                        .width(32)
                        .height(32)
                    }
                    .onClick(() => {
                      this.playVideo(media)
                    })
                  }

                  // 删除按钮
                  Image($r('app.media.delete'))
                    .width(20)
                    .height(20)
                    .fillColor(Color.White)
                    .borderRadius(10)
                    .padding(2)
                    .margin(4)
                    .onClick(() => {
                      this.removeMediaFile(index)
                    })
                }
                .width('100%')
                .height(80)
              }
            })
        }
        .columnsTemplate('1fr 1fr 1fr 1fr')
        .rowsGap(8)
        .columnsGap(8)
      }
      .width('100%')
      .margin({ top: 8 })
    }
  }

  build() {
    Column() {
      // 标题输入
      TextInput({ placeholder: '请输入标题...', text: this.title })
        .onChange((value: string) => {
          this.title = value
        })
        .textInputStyle()
        .fontSize(20)
        .fontWeight(700)

      Column() {
        // 内容输入
        TextArea({ placeholder: '分享你的旅行体验...', text: this.content })
          .onChange((value: string) => {
            this.content = value
          })
          .fontSize(16)
          .height(300)
          .maxLength(1000)
          .margin({ top: 12 })
          .backgroundColor("#fff6f5f5")
          .borderColor("#FFFFF1")

        // 媒体操作按钮
        this.buildMediaActionButtons()

        // 媒体预览区域
        this.buildMediaPreview()
      }
      .textInputStyle()
    }
    .width('100%')
    .padding({ top: 10 })
  }

  // 选择媒体文件
  private async selectMedia(type: MediaType): Promise<void> {
    await ImageOperationUtil.selectMedia(
      type,
      this.context,
      this.mediaFiles,
      (mediaFile: MediaFile) => {
        this.mediaFiles.push(mediaFile);
      },
      (message: string) => {
        this.getUIContext().getPromptAction().showToast({
          message: message,
          duration: 2000
        });
      },
      (options: promptAction.ActionMenuOptions) => {
        return this.getUIContext().getPromptAction().showActionMenu(options);
      }
    );
  }

  // 移除媒体文件
  private removeMediaFile(index: number) {

    if (this.mediaFiles[index].uri?.startsWith('http://') || this.mediaFiles[index].uri?.startsWith('https://')) {
      this.wastedFiles.push(this.mediaFiles[index].uri)
    }
    this.mediaFiles.splice(index, 1)
  }

  // 播放视频
  private playVideo(media: MediaFile): void {
    if (media.type !== MediaType.VIDEO) {
      return
    }
    this.showMediaPreview(media)
  }

  // 预览图片
  private previewImage(media: MediaFile): void {
    if (media.type !== MediaType.IMAGE) {
      return
    }
    this.showMediaPreview(media)
  }

  // 显示媒体预览对话框
  private showMediaPreview(media: MediaFile): void {
    // 重新创建对话框控制器,传入要预览的媒体文件
    this.mediaPreviewDialogController = new CustomDialogController({
      builder: MediaPreviewDialog({ mediaFile: media }),
      autoCancel: true,
      alignment: DialogAlignment.Center,
      customStyle: true,
      isModal: true,
      showInSubWindow: false
    })

    this.mediaPreviewDialogController.open()
  }
}
相关推荐
Georgewu2 小时前
【 HarmonyOS 】错误描述:The certificate has expired! 鸿蒙证书过期如何解决?
harmonyos
Georgewu2 小时前
【HarmonyOS】一步解决弹框集成-快速弹框QuickDialog使用详解
harmonyos
爱笑的眼睛113 小时前
HarmonyOS 应用开发:基于API 12+的现代化开发实践
华为·harmonyos
HarderCoder3 小时前
重学仓颉-11包系统完全指南
harmonyos
冯志浩5 小时前
Harmony Next - 手势的使用(一)
harmonyos·掘金·金石计划
奶糖不太甜7 小时前
鸿蒙ArkUI开发常见问题解决方案:从布局到事件响应全解析
harmonyos·arkui
鸿蒙先行者7 小时前
鸿蒙调试工具连接失败解决方案与案例分析
harmonyos
鸿蒙小灰7 小时前
ArkWeb优化方法及案例
harmonyos·arkweb
HarmonyOS小助手8 小时前
货拉拉开源两款三方库,为鸿蒙应用高效开发贡献力量
harmonyos·鸿蒙·鸿蒙生态
HarderCoder11 小时前
重学仓颉-10集合类型完全指南:从基础到高级应用
harmonyos