鸿蒙APP开发-带你走进胶片录的相机控制

胶片录的相机控制:从Web getUserMedia到鸿蒙@ohos.camera

如果你是胶片摄影爱好者,推荐去鸿蒙应用市场搜一下**「胶片录」**,下载体验体验。管理胶卷、记录拍摄参数、追踪冲洗流程,一套走下来对胶片摄影的全流程会有更清晰的把控。体验完再回来看这篇文章,你会更清楚相机控制和参数记录背后是怎么实现的。


写在前面

大家好,我是一名写了十多年Web前端的老兵。从jQuery时代一路走到React/Vue,CSS3动画、requestAnimationFrame、Web Animation API这些都算是看家本领。去年开始转战鸿蒙生态,用ArkTS开发App,这一路踩了不少坑,也积累了不少心得。

很多人觉得"前端转鸿蒙"应该很容易------都是写UI嘛,组件化、状态管理、生命周期,概念都差不多。但真正上手之后你会发现,相似的地方让你觉得亲切,不同的地方让你抓狂

比如:

  • 相机调用 :Web里用getUserMedia加上<video>标签就能预览和拍照;鸿蒙里得用@ohos.camera,要自己创建相机实例、配置会话、管理输出流,完全是另一套体系。
  • 图片处理 :Web里拍照后可以直接canvas.toDataURL();鸿蒙里得用image.createImageSource()来处理图片数据,API完全不同。
  • 状态管理 :React的useState变成了@State,看起来像,但更新机制完全不同------React是函数式触发重渲染,ArkTS是装饰器驱动的精准更新。
  • 参数记录 :Web里用localStorage存JSON就行,鸿蒙里用@ohos.data.preferences,从同步变成了异步,还得手动flush()

但别担心,核心思想是一样的:都是组件化开发,都是数据驱动UI,都有生命周期管理。你之前积累的前端经验,在鸿蒙里依然是你的核心竞争力。

接下来这篇文章,我会用"胶片录"的实际开发经历,带你看看鸿蒙的相机API怎么控制拍摄参数------从相机初始化到参数配置,再到拍照保存。代码我会同时给出React版本和ArkTS版本,让你能直观地看到两者的对应关系。


这篇文章聊什么

胶片录这个App,核心要解决的问题是:帮胶片爱好者记录每次拍摄的参数。光圈、快门、ISO、胶卷型号、拍摄场景......这些信息都要记下来。

但这里有个有趣的点------胶片录不是真的用手机相机去"拍照",而是帮你管理拍摄参数。不过,有些场景下用户可能想拍一张"参数参考照",比如记录当时的光线条件。所以我们还是需要相机功能。

对应到HarmonyOS的API,主要涉及:

  • @ohos.camera --- 相机控制
  • @ohos.data.preferences --- 参数数据存储

第一步:先设计数据结构

在写任何代码之前,你得先想明白"要存什么"。胶片录里一条拍摄记录长这样:

typescript 复制代码
// 拍摄记录
interface ShotRecord {
  id: string;            // 唯一标识
  filmId: string;        // 使用的胶卷ID
  frameNumber: number;   // 帧号
  aperture: string;      // 光圈值,如 "f/2.8"
  shutterSpeed: string;  // 快门速度,如 "1/125"
  iso: number;           // ISO值
  focalLength: number;   // 焦距(mm)
  scene: string;         // 拍摄场景
  notes: string;         // 备注
  photoUri: string;      // 参考照片路径(可选)
  timestamp: number;     // 拍摄时间戳
}

// 胶卷信息
interface FilmRoll {
  id: string;
  brand: string;         // 品牌:Kodak/Fujifilm/Ilford等
  model: string;         // 型号
  format: string;        // 格式:35mm/120/4x5
  iso: number;           // 胶卷感光度
  totalFrames: number;   // 总帧数
  status: string;        // 状态:未开封/拍摄中/已拍完/已冲洗
}

拍摄记录里有photoUri字段,用来保存参考照片。虽然胶片录主要是记录参数,但有时候用户想拍一张参考照方便后续回忆当时的拍摄环境。


第二步:相机初始化------先看React里怎么做

在Web里调用相机,通常用getUserMedia

tsx 复制代码
// React版本 - 相机初始化
function CameraView() {
  const videoRef = useRef(null);
  const [isReady, setIsReady] = useState(false);

  const startCamera = async () => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        video: {
          facingMode: 'environment', // 后置摄像头
          width: { ideal: 1920 },
          height: { ideal: 1080 }
        }
      });
      videoRef.current.srcObject = stream;
      setIsReady(true);
    } catch (err) {
      console.error('相机启动失败:', err);
    }
  };

  return (
    <div>
      <video ref={videoRef} autoPlay playsInline />
      <button onClick={startCamera}>打开相机</button>
    </div>
  );
}

Web的相机API非常简洁,一行getUserMedia就能拿到视频流。但在鸿蒙里,相机的体系完全不同。


第三步:ArkTS版本------相机初始化

鸿蒙的相机API有几个核心概念,先搞清楚:

  • CameraManager:相机管理器,用来获取相机列表和支持的配置
  • CameraDevice:具体的相机设备(前置/后置)
  • CameraInput:相机输入,代表你要使用的摄像头
  • PreviewOutput:预览输出,把相机画面显示到屏幕上
  • PhotoOutput:照片输出,用来拍照
  • CaptureSession:拍照会话,把输入和输出连起来
typescript 复制代码
import { camera } from '@kit.CameraKit';

@Entry
@Component
struct CameraControlPage {
  @State previewSurfaceId: string = ''
  @State isCameraReady: boolean = false
  @State currentAperture: string = 'f/2.8'
  @State currentISO: number = 400

  private cameraManager: camera.CameraManager | null = null
  private cameraDevice: camera.CameraDevice | null = null
  private captureSession: camera.CaptureSession | null = null
  private previewOutput: camera.PreviewOutput | null = null
  private photoOutput: camera.PhotoOutput | null = null

  async aboutToAppear() {
    await this.initCamera()
  }

  aboutToDisappear() {
    this.releaseCamera()
  }

  // 初始化相机------九步走
  async initCamera() {
    try {
      // 第一步:获取相机管理器
      this.cameraManager = camera.getCameraManager(getContext())

      // 第二步:获取相机列表
      const cameras = this.cameraManager.getSupportedCameras()
      if (cameras.length === 0) {
        console.error('没有可用的相机')
        return
      }

      // 第三步:选择后置摄像头
      // 胶片录拍参考照,通常用后置拍场景
      this.cameraDevice = cameras.find(
        c => c.cameraPosition === camera.CameraPosition.CAMERA_POSITION_BACK
      ) || cameras[0]

      // 第四步:获取支持的输出配置
      const configs = this.cameraManager.getSupportedOutputCapability(this.cameraDevice)
      if (configs.length === 0) {
        console.error('没有支持的输出配置')
        return
      }
      const profile = configs[0]

      // 第五步:创建预览输出
      this.previewOutput = this.cameraManager.createPreviewOutput(
        profile.previewProfiles[0],
        this.previewSurfaceId
      )

      // 第六步:创建照片输出
      this.photoOutput = this.cameraManager.createPhotoOutput(profile.photoProfiles[0])

      // 第七步:创建拍照会话
      this.captureSession = this.cameraManager.createCaptureSession()
      this.captureSession.beginConfig()

      // 第八步:添加输入和输出
      const cameraInput = this.cameraManager.createCameraInput(this.cameraDevice)
      cameraInput.open()
      this.captureSession.addInput(cameraInput)
      this.captureSession.addOutput(this.previewOutput)
      this.captureSession.addOutput(this.photoOutput)

      // 第九步:提交配置并开始预览
      await this.captureSession.commitConfig()
      await this.captureSession.start()

      this.isCameraReady = true
    } catch (err) {
      console.error(`初始化相机失败: ${err}`)
    }
  }

  // 释放相机资源
  releaseCamera() {
    if (this.captureSession) {
      this.captureSession.release()
      this.captureSession = null
    }
    if (this.previewOutput) {
      this.previewOutput.release()
      this.previewOutput = null
    }
    if (this.photoOutput) {
      this.photoOutput.release()
      this.photoOutput = null
    }
  }

  build() {
    Column() {
      // 相机预览区域
      XComponent({
        id: 'camera_preview',
        type: XComponentType.SURFACE,
        libraryname: ''
      })
        .width('100%')
        .aspectRatio(3 / 4)
        .backgroundColor('#000')

      // 底部控制区------拍摄参数显示
      Row() {
        Column() {
          Text('光圈')
            .fontSize(12)
            .fontColor('#9CA3AF')
          Text(this.currentAperture)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
        }

        Column() {
          Text('ISO')
            .fontSize(12)
            .fontColor('#9CA3AF')
          Text(`${this.currentISO}`)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
        }

        Button('拍照')
          .onClick(() => this.takePhoto())
          .width(64)
          .height(64)
          .backgroundColor('#F59E0B')
          .borderRadius(32)
          .enabled(this.isCameraReady)
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceAround)
      .padding(20)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#1a1a2e')
  }
}

看着代码量比Web版本多很多,对吧?但每个步骤都有明确的职责。鸿蒙的相机API是面向"多场景"设计的,你可能同时需要预览+拍照+录像,每个场景都有不同的输入输出组合。


第四步:拍照并保存

拍照的核心是调用photoOutput.capture(),然后监听拍照完成事件:

typescript 复制代码
import { fileIo } from '@kit.CoreFileKit';
import { image } from '@kit.ImageKit';

// 拍照
async takePhoto() {
  if (!this.photoOutput || this.isTaking) return
  this.isTaking = true

  try {
    const photo = await this.photoOutput.capture()

    photo.on('photoAvailable', async (err, photoResult) => {
      if (err) {
        console.error(`拍照失败: ${err}`)
        this.isTaking = false
        return
      }

      // 获取图片数据
      const mainImage = photoResult.main
      const imageBuffer = mainImage

      // 保存到应用目录
      const filePath = await this.savePhoto(imageBuffer)
      this.capturedPhotoUri = filePath
      this.isTaking = false
    })
  } catch (err) {
    console.error(`拍照异常: ${err}`)
    this.isTaking = false
  }
}

// 保存照片
async savePhoto(imageBuffer: ArrayBuffer): Promise<string> {
  try {
    const fileName = `shot_${Date.now()}.jpg`
    const filePath = `${getContext().filesDir}/${fileName}`

    const file = fileIo.openSync(filePath,
      fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY)
    fileIo.writeSync(file.fd, imageBuffer)
    fileIo.closeSync(file)

    return filePath
  } catch (err) {
    console.error(`保存照片失败: ${err}`)
    return ''
  }
}

在React版本里,拍照后直接用canvas.toBlob()就能拿到图片数据。鸿蒙里需要通过photoAvailable回调获取图片数据,然后用fileIo写入文件。


第五步:用preferences保存拍摄参数

拍完照(或者不拍照只记录参数),要把拍摄参数存下来。

typescript 复制代码
import { preferences } from '@kit.ArkData';

// React版本
function saveShotRecord(record) {
  const records = JSON.parse(localStorage.getItem('shot_records') || '[]');
  records.push(record);
  localStorage.setItem('shot_records', JSON.stringify(records));
}

// ArkTS版本
async function saveShotRecord(context: Context, record: ShotRecord): Promise<void> {
  try {
    const store = await preferences.getPreferences(context, 'jiaopianlu_data');
    let records: ShotRecord[] = [];
    const stored = await store.get('shot_records', '[]');
    records = JSON.parse(stored as string);

    records.push(record);
    // 按时间倒序排列
    records.sort((a, b) => b.timestamp - a.timestamp);

    await store.set('shot_records', JSON.stringify(records));
    await store.flush(); // 别忘了flush!
  } catch (err) {
    console.error(`保存拍摄记录失败: ${err}`);
  }
}

关键区别:preferences的所有操作都是异步的 ,而且必须调用flush()才能真正写入磁盘。这个和Web的localStorage完全不同------localStorage是同步的,写入立刻生效。


第六步:完整的拍摄参数记录页面

把相机和参数记录整合到一起。用户可以选择拍照记录,也可以只记录参数不拍照:

typescript 复制代码
@Entry
@Component
struct ShotRecordPage {
  @State filmRolls: FilmRoll[] = []
  @State selectedFilmId: string = ''
  @State aperture: string = 'f/2.8'
  @State shutterSpeed: string = '1/125'
  @State iso: number = 400
  @State scene: string = '日常'
  @State notes: string = ''
  @State showCamera: boolean = false
  @State capturedPhotoUri: string = ''

  // 光圈选项
  private apertures: string[] = [
    'f/1.4', 'f/2', 'f/2.8', 'f/4', 'f/5.6', 'f/8', 'f/11', 'f/16', 'f/22'
  ]

  // 快门速度选项
  private shutterSpeeds: string[] = [
    '1/1000', '1/500', '1/250', '1/125', '1/60', '1/30', '1/15', '1/8', '1/4', '1/2', '1"'
  ]

  // 场景选项
  private scenes: string[] = [
    '日常', '人像', '风光', '街拍', '夜景', '微距', '运动', '建筑'
  ]

  build() {
    Column() {
      // 顶部标题
      Text('记录拍摄参数')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 16 })

      // 胶卷选择
      if (this.filmRolls.length > 0) {
        Row() {
          Text('胶卷:')
            .fontSize(14)
          Select(this.filmRolls.map(f => ({
            value: `${f.brand} ${f.model} (${f.format})`
          })))
            .selected(0)
            .onSelect((index: number) => {
              this.selectedFilmId = this.filmRolls[index].id
            })
        }
        .width('100%')
        .margin({ bottom: 12 })
      }

      // 光圈选择
      Text('光圈')
        .fontSize(14)
        .fontColor('#9CA3AF')
        .margin({ bottom: 4 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(this.apertures, (ap: string) => {
          Text(ap)
            .fontSize(14)
            .padding(8)
            .margin(4)
            .borderRadius(8)
            .backgroundColor(this.aperture === ap ? '#F59E0B' : '#374151')
            .fontColor(this.aperture === ap ? '#FFF' : '#D1D5DB')
            .onClick(() => { this.aperture = ap })
        })
      }
      .margin({ bottom: 12 })

      // 快门速度选择
      Text('快门速度')
        .fontSize(14)
        .fontColor('#9CA3AF')
        .margin({ bottom: 4 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(this.shutterSpeeds, (speed: string) => {
          Text(speed)
            .fontSize(14)
            .padding(8)
            .margin(4)
            .borderRadius(8)
            .backgroundColor(this.shutterSpeed === speed ? '#F59E0B' : '#374151')
            .fontColor(this.shutterSpeed === speed ? '#FFF' : '#D1D5DB')
            .onClick(() => { this.shutterSpeed = speed })
        })
      }
      .margin({ bottom: 12 })

      // ISO选择
      Row() {
        Text('ISO')
          .fontSize(14)
          .fontColor('#9CA3AF')
        Slider({
          value: this.iso,
          min: 50,
          max: 6400,
          step: 1
        })
          .onChange((value: number) => {
            // 对齐到常用ISO值
            const commonISOs = [50, 100, 200, 400, 800, 1600, 3200, 6400]
            const closest = commonISOs.reduce((prev, curr) =>
              Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev
            )
            this.iso = closest
          })
          .layoutWeight(1)
        Text(`${this.iso}`)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .width(50)
          .textAlign(TextAlign.Center)
      }
      .width('100%')
      .margin({ bottom: 12 })

      // 拍照按钮(可选)
      Button(this.showCamera ? '关闭相机' : '拍参考照')
        .onClick(() => { this.showCamera = !this.showCamera })
        .width('100%')
        .backgroundColor('#374151')

      // 保存按钮
      Button('保存拍摄记录')
        .onClick(() => this.saveRecord())
        .width('100%')
        .backgroundColor('#F59E0B')
        .margin({ top: 12 })
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#1a1a2e')
  }

  async saveRecord() {
    const record: ShotRecord = {
      id: `shot_${Date.now()}`,
      filmId: this.selectedFilmId,
      frameNumber: 1,
      aperture: this.aperture,
      shutterSpeed: this.shutterSpeed,
      iso: this.iso,
      focalLength: 50,
      scene: this.scene,
      notes: this.notes,
      photoUri: this.capturedPhotoUri,
      timestamp: Date.now()
    }

    await saveShotRecord(getContext(), record)
    console.info('拍摄记录保存成功')
  }
}

第七步:常见问题和踩坑

7.1 相机权限

问题:调用相机API报错"permission denied"。

解决 :在module.json5里声明相机权限,并在运行时请求。

json 复制代码
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.CAMERA",
        "reason": "拍摄参考照片需要使用相机",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}

7.2 相机资源释放

问题:退出拍照页面后,相机指示灯还亮着。

原因 :忘记释放相机资源。务必在aboutToDisappear里调用release()

7.3 ISO值对齐

问题:滑块拖动时ISO值不连续,用户体验差。

解决 :用reduce找到最接近的常用ISO值,而不是直接用滑块的原始值。


总结

这篇文章围绕"胶片录"的相机控制功能,把HarmonyOS的相机API过了一遍:

  1. 相机初始化:九步走流程------获取管理器、选择摄像头、创建输入输出、建立会话
  2. 拍照保存photoOutput.capture() + fileIo写入文件
  3. 参数记录@ohos.data.preferences异步存储拍摄参数
  4. UI设计:光圈/快门/ISO的选择器,用Flex布局实现标签式选择

相机API是鸿蒙里比较复杂的一个,但你只要理解了"输入-会话-输出"这个模型,就抓住了核心。胶片录虽然主要是记录参数,但相机功能让用户能拍参考照,两者配合起来才是完整的拍摄记录体验。


如果你也是胶片摄影爱好者,希望这篇文章能帮你理解胶片录背后的实现逻辑。去鸿蒙应用市场下载体验一下吧,有问题欢迎交流。

相关推荐
陈_杨1 小时前
鸿蒙APP开发-带你走进节流战的Canvas图表
前端·javascript
陈_杨1 小时前
鸿蒙APP开发-带你走进光绘记的拍摄规划
前端·javascript
陈_杨1 小时前
鸿蒙APP开发-带你走进光绘记的长曝光模拟
前端·javascript
陈_杨1 小时前
鸿蒙APP开发-带你走进节拍器的声音怎么这么准
前端·javascript
搬砖的阿wei1 小时前
Pinia 与 Vuex 区别
前端·vue.js
KaMeidebaby1 小时前
卡梅德生物技术快报|原核表达系统工艺优化:包涵体重折叠 + 分子筛纯化实现功能 RBD 高效制备,附全参数配置
前端·人工智能·算法·数据挖掘·数据分析
最爱睡觉睡觉睡觉1 小时前
代碼案例:CSS 屬性對照
前端·app
VitoChang2 小时前
开发体验超赞的SolidJS2.0来了
前端
CoCo的编程之路2 小时前
2026全栈演进:使用前端开发助手进行项目重构的最佳工具
大数据·前端·人工智能·ai编程·comate