胶片录的相机控制:从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过了一遍:
- 相机初始化:九步走流程------获取管理器、选择摄像头、创建输入输出、建立会话
- 拍照保存 :
photoOutput.capture()+fileIo写入文件 - 参数记录 :
@ohos.data.preferences异步存储拍摄参数 - UI设计:光圈/快门/ISO的选择器,用Flex布局实现标签式选择
相机API是鸿蒙里比较复杂的一个,但你只要理解了"输入-会话-输出"这个模型,就抓住了核心。胶片录虽然主要是记录参数,但相机功能让用户能拍参考照,两者配合起来才是完整的拍摄记录体验。
如果你也是胶片摄影爱好者,希望这篇文章能帮你理解胶片录背后的实现逻辑。去鸿蒙应用市场下载体验一下吧,有问题欢迎交流。