在这里插入图片描述

一、我们要做什么

1.1 需求全景图

在一个典型的社交或内容类 UniApp 应用中,多媒体处理覆盖以下核心场景:

功能模块 具体需求 技术挑战
拍照 高分辨率照片采集,支持前置/后置摄像头 分辨率适配、Exif 方向、鸿蒙权限粒度
录像 1080P 视频录制,含音频轨道,时长控制 编码参数、存储路径、音视频同步
视频播放 本地/网络视频流播放,进度控制 鸿蒙 AVPlayer 与 Web API 差异
音频播放 后台音乐播放,通知栏控制 后台服务保活、AudioFocus
图片压缩 上传前压缩,控制文件体积与尺寸 有损/无损平衡、EXIF 剥离、鸿蒙 Image Kit
图片裁剪 圆角裁剪、缩放、旋转 手势交互、矩阵变换
文件选择 从相册选择图片/视频 多媒体扫描、权限兜底

1.2 预期效果

以一个"内容发布"页面为例,用户点击"拍照"按钮,调用系统相机拍摄 16:9 照片,系统自动压缩至 1MB 以内、宽不超过 1920px,用户可在压缩图上进行圆角裁剪确认,最终生成 800KB 左右的 JPEG 文件提交上传。视频场景同理:录制 30 秒 MP4(1080P H.264 + AAC),系统自动裁剪至 15MB 以内,生成可网络传输的 MP4 文件。音频场景则需要支持后台播放、进度拖动、播放速度调整。

1.3 技术挑战

跨端能力差异是首要挑战。iOS/Android/鸿蒙三端的媒体 API 设计差异显著,UniApp 统一抽象层能力有限,需通过原生插件实现平台差异化。权限模型差异方面,Android 13+ 的细粒度媒体权限与鸿蒙的 ACL 声明机制各不相同,原生插件层需统一兜底。压缩质量平衡直接影响用户体验,过大文件上传失败,过度压缩损失画质,需精细化配置。


二、数据模型设计

2.1 媒体资产抽象

为了在跨端场景下统一管理不同类型的媒体数据,我们定义以下 TypeScript 接口:

// types/media.ts

/** 媒体类型枚举 */
export enum MediaType {
  IMAGE = 'image',
  VIDEO = 'video',
  AUDIO = 'audio',
}

/** 图片方向 */
export enum Orientation {
  UP = 1,
  DOWN = 3,
  LEFT = 8,
  RIGHT = 6,
}

/** 媒体资产元数据 */
export interface MediaAsset {
  /** 唯一标识 */
  id: string;
  /** 文件路径(本地)或 URL(远程) */
  uri: string;
  /** 媒体类型 */
  type: MediaType;
  /** 文件大小(字节) */
  size: number;
  /** MIME 类型 */
  mimeType: string;
  /** 宽度(图片/视频) */
  width?: number;
  /** 高度(图片/视频) */
  height?: number;
  /** 时长(音视频)单位:秒 */
  duration?: number;
  /** 缩略图路径 */
  thumbnail?: string;
  /** 创建时间戳 */
  createdAt: number;
  /** EXIF 方向 */
  orientation?: Orientation;
  /** 额外元数据 */
  metadata?: Record<string, unknown>;
}

/** 压缩质量等级 */
export enum CompressionQuality {
  LOW = 0.3,
  MEDIUM = 0.6,
  HIGH = 0.85,
  LOSSLESS = 1.0,
}

/** 图片压缩配置 */
export interface ImageCompressConfig {
  /** 最大宽度 */
  maxWidth: number;
  /** 最大高度 */
  maxHeight: number;
  /** 质量系数 0-1 */
  quality: CompressionQuality;
  /** 输出格式 */
  format: 'jpeg' | 'png' | 'webp';
  /** 是否剥离 EXIF */
  stripExif: boolean;
  /** 文件大小上限(字节)默认 2MB */
  maxSizeBytes?: number;
  /** 是否启用渐进式 JPEG */
  progressive?: boolean;
}

/** 视频压缩配置 */
export interface VideoCompressConfig {
  /** 视频编码器 */
  codec: 'h264' | 'hevc';
  /** 视频码率(bps)0 表示自动 */
  videoBitrate: number;
  /** 音频码率(bps) */
  audioBitrate: number;
  /** 输出分辨率 */
  resolution: '720p' | '1080p' | '4k';
  /** 帧率 */
  fps: number;
  /** 时长上限(秒) */
  maxDuration?: number;
  /** 文件大小上限(字节)默认 20MB */
  maxSizeBytes?: number;
}

/** 拍照配置 */
export interface CaptureConfig {
  /** 摄像头类型 */
  cameraType: 'back' | 'front';
  /** 照片质量 0-100 */
  quality: number;
  /** 是否启用闪光灯 */
  flash: 'auto' | 'on' | 'off';
  /** 输出格式 */
  format: 'jpeg' | 'png';
  /** 是否保存到相册 */
  saveToAlbum: boolean;
  /** 裁剪配置,为 null 则不裁剪 */
  cropConfig?: {
    aspectRatioX: number;
    aspectRatioY: number;
    circleCrop: boolean;
  };
}

/** 录像配置 */
export interface RecordConfig {
  /** 最大时长(秒) */
  maxDuration: number;
  /** 视频质量 */
  quality: 'low' | 'medium' | 'high';
  /** 是否录制音频 */
  recordAudio: boolean;
  /** 摄像头类型 */
  cameraType: 'back' | 'front';
  /** 录制完成自动压缩 */
  autoCompress: boolean;
}

/** 文件选择配置 */
export interface FilePickerConfig {
  /** 允许选择的媒体类型 */
  mediaType: MediaType[];
  /** 最大文件数 */
  maxCount: number;
  /** 单个文件大小上限 */
  maxSizeBytes?: number;
  /** 是否允许拍摄/录制 */
  captureEnabled: boolean;
}

三、核心设计决策

3.1 方案对比总览

维度 纯 JS 方案(canvas / Web Audio API) 原生插件方案(AVPlayer / Image Kit)
压缩质量 中等,受限于 JS 性能,无法控制硬件编码器 优秀,可调用硬件编解码器,质量可控
处理速度 慢(单线程 JS,大图易卡顿) 快(原生多线程,硬件加速)
视频能力 基本不支持或极简化 完整支持 H.264/HEVC/AAC 硬编码
鸿蒙适配 无需适配,但能力受限 需开发鸿蒙原生插件,能力完整
包体积 仅 JS 文件,增量小 需引入 so 库,增量约 3-8MB
内存占用 高(canvas 解析大图) 低(流式处理,内存占用稳定)
适合场景 简单裁剪、头像压缩 生产级媒体处理、视频上传

3.2 选型理由

拍照/录像:原生插件优先。虽然 UniApp 提供了 uni.chooseImageuni.chooseVideo,但仅能获取结果文件,无法精细控制拍摄参数(曝光、对焦、分辨率)。原生插件方案可暴露 CameraPicker(鸿蒙)或 AVCaptureSession(iOS)全部参数,生产级应用必须采用。

视频播放:分层策略。短视频(<30s)使用 <video> 标签配合 HLS/DASH,自适应码率,体验流畅;长视频和高质量场景使用鸿蒙 AVPlayer 原生插件,支持 DRM、倍速播放、画中画等高级特性。两者通过统一的 VideoPlayerFacade 外观类屏蔽差异。

图片压缩:渐进式策略。压缩流程为:先用 JS 端 canvas 快速做尺寸缩放(降低解码压力),再交由原生插件完成质量压缩。若 maxSizeBytes 未满足,继续降低质量或增大压缩比,直至达标。鸿蒙端使用 image.createImagePackerPackingStrategy 实现精确的字节控制。

音频播放:AVPlayer 统一封装。鸿蒙的 AVPlayer 同时支持音频和视频,是系统统一的播放器实现。相比 iOS 的 AVAudioPlayer 和 Android 的 MediaPlayer,AVPlayer 接口更现代(基于 Promise / Observer 模式),可直接复用音频播放能力。


四、完整代码实现

4.1 插件封装层(TypeScript 统一接口)

// plugins/media-plugin.ts

import { defineAsyncComponent } from 'vue';
import type {
  MediaAsset,
  ImageCompressConfig,
  VideoCompressConfig,
  CaptureConfig,
  RecordConfig,
  FilePickerConfig,
  MediaType,
} from '../types/media';

/** 各平台插件实例 */
interface PlatformPlugin {
  captureImage(config: CaptureConfig): Promise<MediaAsset>;
  startRecording(config: RecordConfig): Promise<MediaAsset>;
  compressImage(uri: string, config: ImageCompressConfig): Promise<MediaAsset>;
  compressVideo(uri: string, config: VideoCompressConfig): Promise<MediaAsset>;
  pickFiles(config: FilePickerConfig): Promise<MediaAsset[]>;
  playVideo(asset: MediaAsset): Promise<void>;
  playAudio(asset: MediaAsset): Promise<void>;
  stopMedia(): void;
  generateThumbnail(uri: string, timeMs?: number): Promise<string>;
  cropImage(uri: string, crop: NonNullable<CaptureConfig['cropConfig']>): Promise<MediaAsset>;
}

/** 平台检测与插件加载 */
function loadPlatformPlugin(): PlatformPlugin {
  // #ifdef APP-HARMONY
  return defineAsyncComponent(() => import('./harmony/media-harmony.plugin')) as unknown as PlatformPlugin;
  // #endif

  // #ifdef APP-IOS
  return defineAsyncComponent(() => import('./ios/media-ios.plugin')) as unknown as PlatformPlugin;
  // #endif

  // #ifdef APP-ANDROID
  return defineAsyncComponent(() => import('./android/media-android.plugin')) as unknown as PlatformPlugin;
  // #endif

  // #ifdef H5
  return defineAsyncComponent(() => import('./h5/media-h5.plugin')) as unknown as PlatformPlugin;
  // #endif

  throw new Error('Unsupported platform');
}

let pluginInstance: PlatformPlugin | null = null;

export function getMediaPlugin(): PlatformPlugin {
  if (!pluginInstance) {
    pluginInstance = loadPlatformPlugin();
  }
  return pluginInstance;
}

4.2 鸿蒙原生插件(ArkTS 核心实现)

以下为鸿蒙端的原生插件核心代码,展示与系统 API 的交互逻辑:

// plugins/harmony/media-harmony.plugin.ts(TypeScript 导出包装)

import { nativePlugin } from '@uni-helper/native-plugin';
import type {
  MediaAsset,
  ImageCompressConfig,
  VideoCompressConfig,
  CaptureConfig,
  RecordConfig,
  FilePickerConfig,
} from '../../types/media';
import {
  createUploadImageConfig,
  createAvatarImageConfig,
} from '../../utils/media-config';

export class HarmonyMediaPlugin {
  private avPlayer: unknown = null;
  private recordingPath: string = '';

  async captureImage(config: CaptureConfig): Promise<MediaAsset> {
    const result = await nativePlugin.call('camera', 'capture', {
      cameraType: config.cameraType,
      quality: config.quality,
      flash: config.flash,
      format: config.format,
    });

    return this.buildMediaAsset(result, 'image');
  }

  async compressImage(uri: string, config?: Partial<ImageCompressConfig>): Promise<MediaAsset> {
    const cfg = { ...createUploadImageConfig(), ...config };
    const result = await nativePlugin.call('image', 'compress', {
      uri,
      maxWidth: cfg.maxWidth,
      maxHeight: cfg.maxHeight,
      quality: cfg.quality,
      format: cfg.format,
      stripExif: cfg.stripExif,
      maxSizeBytes: cfg.maxSizeBytes,
    });

    return this.buildMediaAsset(result, 'image');
  }

  async pickFiles(config: FilePickerConfig): Promise<MediaAsset[]> {
    const result = await nativePlugin.call('picker', 'select', {
      mediaType: config.mediaType,
      maxCount: config.maxCount,
      maxSizeBytes: config.maxSizeBytes,
    });

    return (result.files || []).map((f: Record<string, unknown>) =>
      this.buildMediaAsset(f, f.type as string)
    );
  }

  async playVideo(asset: MediaAsset): Promise<void> {
    await nativePlugin.call('avPlayer', 'play', {
      uri: asset.uri,
      type: 'video',
    });
  }

  async playAudio(asset: MediaAsset): Promise<void> {
    await nativePlugin.call('avPlayer', 'play', {
      uri: asset.uri,
      type: 'audio',
    });
  }

  stopMedia(): void {
    nativePlugin.call('avPlayer', 'stop', {});
  }

  private buildMediaAsset(raw: Record<string, unknown>, type: string) {
    return {
      id: raw.id as string || `${Date.now()}`,
      uri: raw.uri as string,
      type,
      size: Number(raw.size) || 0,
      mimeType: raw.mimeType as string || 'application/octet-stream',
      width: Number(raw.width) || undefined,
      height: Number(raw.height) || undefined,
      duration: Number(raw.duration) || undefined,
      thumbnail: raw.thumbnail as string || undefined,
      createdAt: Number(raw.createdAt) || Date.now(),
    } as MediaAsset;
  }
}

export default new HarmonyMediaPlugin();

4.3 Vue 3 组合式函数(业务层封装)

// composables/useMediaCapture.ts

import { ref, computed } from 'vue';
import { getMediaPlugin } from '../plugins/media-plugin';
import {
  createCaptureConfig,
  createRecordConfig,
  createUploadImageConfig,
  createAvatarImageConfig,
} from '../utils/media-config';
import type {
  MediaAsset,
  CaptureConfig,
  RecordConfig,
  ImageCompressConfig,
} from '../types/media';

export function useMediaCapture() {
  const plugin = getMediaPlugin();

  const isCapturing = ref(false);
  const isCompressing = ref(false);
  const lastAsset = ref<MediaAsset | null>(null);
  const error = ref<string | null>(null);

  /** 拍照并自动压缩 */
  async function takePhoto(options?: Partial<CaptureConfig>): Promise<MediaAsset | null> {
    isCapturing.value = true;
    error.value = null;

    try {
      const config = { ...createCaptureConfig(), ...options };
      const raw = await plugin.captureImage(config);
      lastAsset.value = raw;

      // 自动压缩
      const compressConfig = createUploadImageConfig();
      if (raw.size > compressConfig.maxSizeBytes!) {
        isCompressing.value = true;
        const compressed = await plugin.compressImage(raw.uri, compressConfig);
        lastAsset.value = compressed;
        return compressed;
      }

      return raw;
    } catch (e: unknown) {
      error.value = e instanceof Error ? e.message : String(e);
      return null;
    } finally {
      isCapturing.value = false;
      isCompressing.value = false;
    }
  }

  /** 录像 */
  async function recordVideo(options?: Partial<RecordConfig>): Promise<MediaAsset | null> {
    isCapturing.value = true;
    error.value = null;

    try {
      const config = { ...createRecordConfig(), ...options };
      return await plugin.startRecording(config);
    } catch (e: unknown) {
      error.value = e instanceof Error ? e.message : String(e);
      return null;
    } finally {
      isCapturing.value = false;
    }
  }

  const statusText = computed(() => {
    if (isCapturing.value) return '采集中…';
    if (isCompressing.value) return '压缩中…';
    return '就绪';
  });

  return {
    isCapturing: readonly(isCapturing),
    isCompressing: readonly(isCompressing),
    lastAsset: readonly(lastAsset),
    error: readonly(error),
    statusText,
    takePhoto,
    recordVideo,
    stopMedia: () => plugin.stopMedia(),
  };
}

4.4 Vue 组件实现

<!-- components/MediaCapturePanel.vue -->

<template>
  <view class="capture-panel">
    <!-- 状态栏 -->
    <view class="status-bar">
      <text class="status-text">{{ statusText }}</text>
      <text v-if="lastAsset" class="asset-info">
        {{ lastAsset.width }}×{{ lastAsset.height }} · {{ formatSize(lastAsset.size) }}
      </text>
    </view>

    <!-- 预览区 -->
    <view class="preview-area">
      <image
        v-if="lastAsset?.type === 'image' && previewUrl"
        :src="previewUrl"
        mode="aspectFit"
        class="preview-image"
      />
      <video
        v-else-if="lastAsset?.type === 'video' && previewUrl"
        :src="previewUrl"
        controls
        class="preview-video"
      />
      <view v-else class="preview-placeholder">
        <text class="placeholder-icon">📷</text>
        <text class="placeholder-hint">点击下方按钮开始</text>
      </view>
    </view>

    <!-- 操作按钮组 -->
    <view class="action-bar">
      <button class="action-btn" :disabled="isCapturing" @click="handleTakePhoto">
        <text>📸 拍照</text>
      </button>
      <button class="action-btn" :disabled="isCapturing" @click="handleRecord">
        <text>🎬 录像</text>
      </button>
      <button class="action-btn" @click="handlePick">
        <text>🖼️ 相册</text>
      </button>
    </view>

    <!-- 错误提示 -->
    <view v-if="error" class="error-toast">
      <text>{{ error }}</text>
    </view>
  </view>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';
import { useMediaCapture } from '../composables/useMediaCapture';
import { getMediaPlugin } from '../plugins/media-plugin';
import { createAvatarImageConfig } from '../utils/media-config';

const { isCapturing, isCompressing, lastAsset, error, statusText, takePhoto, recordVideo } =
  useMediaCapture();

const plugin = getMediaPlugin();

const previewUrl = computed(() => {
  if (!lastAsset.value) return '';
  const { uri, type } = lastAsset.value;
  if (type === 'image' || type === 'video') {
    return uri.startsWith('file://') || uri.startsWith('/') ? uri : `file://${uri}`;
  }
  return '';
});

async function handleTakePhoto() {
  const asset = await takePhoto({ cameraType: 'back', flash: 'auto' });
  if (asset) {
    console.log('拍照成功:', asset.size, 'bytes');
  }
}

async function handleRecord() {
  const asset = await recordVideo({ maxDuration: 30, quality: 'high' });
  if (asset) {
    console.log('录像成功:', asset.duration, 's,', asset.size, 'bytes');
  }
}

async function handlePick() {
  try {
    const assets = await plugin.pickFiles({
      mediaType: ['image', 'video'],
      maxCount: 9,
      captureEnabled: false,
    });

    if (assets.length > 0) {
      // 第一个选中资产设为预览
      const first = assets[0];
      if (first.type === 'image') {
        const compressed = await plugin.compressImage(first.uri, createAvatarImageConfig());
        console.log('选中并压缩:', compressed.size, 'bytes');
      }
    }
  } catch (e) {
    console.error('选择失败:', e);
  }
}

function formatSize(bytes: number): string {
  if (bytes < 1024) return `${bytes}B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
  return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
}
</script>

<style scoped>
.capture-panel {
  display: flex;
  flex-direction: column;
  height: 100%;
  padding: 16rpx;
  box-sizing: border-box;
}
.status-bar {
  flex-direction: row;
  justify-content: space-between;
  padding: 12rpx 16rpx;
  background: #f5f5f5;
  border-radius: 8rpx;
  margin-bottom: 16rpx;
}
.preview-area {
  flex: 1;
  justify-content: center;
  align-items: center;
  background: #1a1a1a;
  border-radius: 12rpx;
  overflow: hidden;
  min-height: 400rpx;
}
.action-bar {
  flex-direction: row;
  justify-content: space-around;
  padding: 24rpx 0;
  gap: 16rpx;
}
.action-btn {
  flex: 1;
  height: 88rpx;
  line-height: 88rpx;
  background: #007aff;
  color: #fff;
  border-radius: 44rpx;
  font-size: 28rpx;
  text-align: center;
}
.action-btn[disabled] {
  opacity: 0.5;
}
.error-toast {
  position: fixed;
  bottom: 120rpx;
  left: 50%;
  transform: translateX(-50%);
  background: rgba(255, 59, 48, 0.9);
  color: #fff;
  padding: 16rpx 32rpx;
  border-radius: 8rpx;
  font-size: 24rpx;
}
</style>

五、深度技术原理

5.1 编码器与格式

JPEG 压缩原理。JPEG 核心流程:RGB → YCbCr → 色度采样(4:2:0)→ DCT → 量化(质量系数控制)→ Huffman 熵编码。鸿蒙 ImagePacker 底层调用 libjpeg-turbo,迭代调整量化矩阵直至满足 maxSizeBytes 约束。剥离 EXIF(相机信息/GPS)可额外减少 10-30KB。

H.264 与 HEVC。H.264 兼容性最强,所有主流平台均有硬件解码支持;HEVC 压缩效率高约 40%,但专利问题导致使用受限。鸿蒙 AVRecorder 默认 H.264 Main Profile,HEVC 需在 VideoEncoder 中显式指定。

5.2 鸿蒙 AVPlayer 架构

鸿蒙 AVPlayer 是统一音视频播放引擎,状态流转:idle → initialized → prepared → playing/paused → stopped → released。关键流程:创建实例 → setUrl 设置数据源 → on('stateChange') 监听状态变化 → play/pause/seek 控制播放 → release 释放。相比 Web <video>,AVPlayer 提供精确毫秒级时间控制、播放速率调整(0.25x - 2.0x)及画中画支持。

5.3 鸿蒙 Image Kit

Image Kit 提供三套核心 API。ImagePacker 将 PixelMap 打包为 JPEG/PNG/WebP,迭代调整量化表直至满足 maxSizeBytes 约束。ImageEditor 提供裁剪、旋转、滤镜等编辑能力,通过 startImageEditor 启动系统级编辑器,结果以回调返回。ImageSource 是图片解码器,通过 DecodingOptions.sampleSize 实现高效下采样(生成缩略图时无需解码全分辨率图片)。

5.4 缩略图生成

视频缩略图的生成有两条路径:

精确路径:通过 AVPlayer 渲染目标时间点帧,输出至 PixelMap,再由 ImagePacker 编码为 JPEG。可精确控制时间点,但需解码视频帧(软/硬解均可能)。生产环境先尝试快速路径(AVMetadataExtractor 直接提取封装层缩略图),无结果时降级至此路径。


六、常见问题解答

Q1:鸿蒙端调用相机权限被拒绝怎么办?

鸿蒙相机权限需在 module.json5 中声明 ohos.permission.CAMERA,并通过 abilityAccessCtrl 检查授权状态。若拒绝,引导跳转至系统设置:getContext().startAbility({ action: 'settings.app.details', uri: 'package://com.example.app' })。鸿蒙 4.0+ 要求在用户可见页面内触发申请,禁止后台静默。

Q2:视频压缩后文件仍然超过预期大小,如何处理?

采用多轮压缩:降分辨率 1080p→720p → 降码率 4Mbps→2Mbps → 降帧率 30→24fps → 改用 HEVC。若仍超标则截取时长,并向用户提示。

Q3:iOS 与鸿蒙的 MIME 类型命名不一致,如何统一?

定义枚举映射表:const MIME_MAP = { 'image/jpeg': 'jpeg', 'image/png': 'png', 'video/mp4': 'mp4', 'audio/mp4': 'mp4', 'audio/mpeg': 'mp3' }。在插件层接收统一类型字符串(不含前缀),各平台插件负责转换为本地格式识别符。

Q4:Web <video> 与原生 AVPlayer 的切换策略是什么?

建议按以下规则决策:文件大小 < 10MB 使用 <video>(加载快,无需插件初始化);HDR 内容或需要 DRM 保护的内容使用 AVPlayer;短内容(< 60s)优先 <video>(交互一致性好);需后台播放或画中画时使用 AVPlayer。统一接口 VideoPlayerFacade 根据策略自动路由。


七、运行效果

在这里插入图片描述

视频播放状态:
在这里插入图片描述

相册选择后压缩进度:
在这里插入图片描述


八、扩展方向

1. 实时推流:结合鸿蒙 LiveStream 或 WebRTC 实现直播能力。

2. AI 辅助处理:接入 HMS ML Kit,实现 OCR、人脸美颜、自动分类等智能功能。

3. 断点续传:大文件分片上传 + xxhash 秒传校验,解决弱网场景下的上传失败问题。

4. 微信小程序适配:通过适配器模式统一插件接口,自动路由至 wx.chooseMedia API,保持跨端代码一致性。

Logo

一站式 AI 云服务平台

更多推荐