相机功能是很多应用的刚需——扫一扫、拍照上传、证件识别都离不开它。但 HarmonyOS 的相机开发不像 Android 那样一个 Intent 搞定你得自己管 CameraManager、Session、Surface 这一套流水线。这篇把 XComponent 预览、拍照保存、PixelMap 裁剪的完整方案讲清楚。XComponent 搭建预览画面相机预览需要一个 Surface 来承载画面流XComponent 就是这个 Surface 的提供者。它的getXComponentSurfaceId()返回的 surfaceId 是整个相机流水线的起点。EntryComponentstruct CameraPage{privatexComponentController:XComponentControllernewXComponentController();StatesurfaceId:string;build(){Column(){XComponent({id:cameraXComponent,type:surface,libraryname:,controller:this.xComponentController,}).onLoad((){// 设置 Surface 尺寸匹配预览分辨率this.xComponentController.setXComponentSurfaceRect({surfaceWidth:1920,surfaceHeight:1080,});this.surfaceIdthis.xComponentController.getXComponentSurfaceId();this.initCamera();}).width(100%).aspectRatio(16/9)}}}XComponent 的type必须是surface不是componentonLoad回调里拿 surfaceId这是创建 PreviewOutput 的必要参数。Surface 尺寸要和预览分辨率匹配否则画面会被拉伸或裁切。注意onLoad是异步触发不能在aboutToAppear里拿 surfaceId那时候 XComponent 还没加载完。必须在onLoad回调里初始化相机。CameraManager 与 Session 初始化拿到 surfaceId 后下一步是创建 CameraManager、找到摄像头设备、建立 CameraSession。整个流程是CameraInput → PreviewOutput → Session → commitConfig → start。import{camera}fromkit.CameraKit;interfaceCameraContext{cameraManager:camera.CameraManager;cameraInput:camera.CameraInput;previewOutput:camera.PreviewOutput;photoOutput:camera.PhotoOutput;session:camera.Session;}privatecameraCtx:CameraContext|undefinedundefined;asyncinitCamera():Promisevoid{if(this.surfaceId){return;}constcameraManager:camera.CameraManagercamera.getCameraManager(getContext(this));// 获取后置摄像头constcameras:Arraycamera.CameraDevicecameraManager.getSupportedCameras();constcameraDevice:camera.CameraDevicecameras[0];constcameraInput:camera.CameraInputcameraManager.createCameraInput(cameraDevice);awaitcameraInput.open();// 查询设备支持的输出能力constcapability:camera.CameraOutputCapabilitycameraManager.getSupportedOutputCapability(cameraDevice,camera.SceneMode.NORMAL_PHOTO);// 创建预览输出constpreviewProfile:camera.Profilecapability.previewProfiles[0];constpreviewOutput:camera.PreviewOutputcameraManager.createPreviewOutput(previewProfile,this.surfaceId);// 创建拍照输出constphotoProfile:camera.Profilecapability.photoProfiles[0];constphotoOutput:camera.PhotoOutputcameraManager.createPhotoOutput(photoProfile);// 建立会话constsession:camera.SessioncameraManager.createSession(camera.SceneMode.NORMAL_PHOTO);session.beginConfig();session.addInput(cameraInput);session.addOutput(previewOutput);session.addOutput(photoOutput);awaitsession.commitConfig();awaitsession.start();this.cameraCtx{cameraManager:cameraManager,cameraInput:cameraInput,previewOutput:previewOutput,photoOutput:photoOutput,session:session,};}这里有几个关键点getSupportedCameras()返回的数组里[0]通常是后置摄像头[1]是前置SceneMode.NORMAL_PHOTO是拍照模式还有NORMAL_VIDEO录像模式Session 的beginConfig→addInput/addOutput→commitConfig→start是固定顺序不能跳步。关键区别beginConfig和commitConfig之间是配置阶段可以反复 add/removecommitConfig之后配置就锁定了想改必须重新beginConfig。拍照与图片保存预览跑起来后拍照就是调photoOutput.capture()。但拿到照片数据需要监听photoAvailable回调系统会把拍照结果通过这个回调推给你。import{image}fromkit.ImageKit;import{fileIoasfs}fromkit.CoreFileKit;StatephotoUri:string;asyncstartPhotoCapture():Promisevoid{if(!this.cameraCtx){return;}// 监听拍照结果this.cameraCtx.photoOutput.on(photoAvailable,(photo:camera.Photo):void{this.savePhotoToFile(photo);});// 触发拍照awaitthis.cameraCtx.photoOutput.capture();}asyncsavePhotoToFile(photo:camera.Photo):Promisevoid{// 通过 PhotoAccessor 获取图片constaccessor:image.ImageAccessorphoto.accessor;constpixelMap:image.PixelMapawaitaccessor.createPixelMap();// 编码为 JPEGconstpacker:image.ImagePackerimage.createImagePacker();constpackOpts:image.PackingOption{format:image/jpeg,quality:95,};constarrayBuffer:ArrayBufferawaitpacker.packing(pixelMap,packOpts);// 写入文件constcontextgetContext(this);constfilePath${context.filesDir}/capture_${Date.now()}.jpg;constfilefs.openSync(filePath,fs.OpenMode.READ_WRITE|fs.OpenMode.CREATE);fs.writeSync(file.fd,arrayBuffer);fs.closeSync(file);this.photoUrifilePath;packer.release();pixelMap.release();}拍照流程是异步的capture()只是触发快门真正的图片数据在photoAvailable回调里拿。拿到 PixelMap 后可以选择直接裁剪也可以先存文件再处理。注意photoAvailable每次拍照都会触发如果连续拍多张回调会多次触发注意在合适的时机off掉监听避免内存泄漏。PixelMap 裁剪与变换拍完照最常见的需求就是裁剪——头像裁圆、证件照裁方。PixelMap 提供了crop、scale、rotate、flip四个变换方法都是异步的。interfaceCropRegion{x:number;y:number;width:number;height:number;}asynccropPixelMap(filePath:string,region:CropRegion):Promiseimage.PixelMap{constsource:image.ImageSourceimage.createImageSource(filePath);constpixelMap:image.PixelMapawaitsource.createPixelMap();// 先裁剪到指定区域awaitpixelMap.crop({x:region.x,y:region.y,size:{width:region.width,height:region.height},});// 缩放到目标尺寸awaitpixelMap.scale(0.5,0.5);returnpixelMap;}asyncrotatePixelMap(pixelMap:image.PixelMap,angle:number):Promisevoid{// 旋转指定角度顺时针awaitpixelMap.rotate(angle);}crop的参数是一个 Region 对象包含x、y和size都是像素值。scale的参数是横纵缩放比例0.5 就是缩小一半。这些操作是链式的——先裁剪再缩放还是先缩放再裁剪结果不同因为坐标体系会变。关键区别crop的坐标是基于当前 PixelMap 尺寸的像素坐标不是原始图片的坐标。如果你先scale缩小了再crop的坐标就要按缩小后的尺寸算。裁剪交互 UI裁剪不是简单调个 API用户需要拖动裁剪框、缩放预览。这就需要手势配合。核心思路是用PanGesture拖动裁剪框用PinchGesture缩放图片裁剪框用四个角的标记可视化。StatecropX:number50;StatecropY:number50;StatecropSize:number200;StatepreviewPixelMap:image.PixelMap|undefinedundefined;BuilderCropOverlay(){Stack(){if(this.previewPixelMap){Image(this.previewPixelMap).objectFit(ImageFit.Contain).width(100%).height(100%)}// 半透明遮罩 裁剪框Stack(){Column().width(100%).height(100%).backgroundColor(#80000000)// 中间透明裁剪区域Row().width(this.cropSize).height(this.cropSize).position({x:this.cropX,y:this.cropY}).border({width:2,color:#FFFFFF,style:BorderStyle.Solid})}.gesture(PanGesture().onActionUpdate((event:GestureEvent){this.cropXevent.offsetX;this.cropYevent.offsetY;}))}.width(100%).height(400)}手势偏移量event.offsetX/offsetY是相对于上次回调的增量所以用累加。裁剪框的边界需要做 clamp不能拖出图片范围。四个角可以再加小方块做拖拽手柄用PinchGesture控制裁剪框大小。注意手势坐标是 vp 单位而 PixelMap 的 crop 坐标是像素单位两者之间需要根据图片显示比例做换算。最简单的方式是记录图片在屏幕上的实际显示尺寸用cropX / displayWidth * pixelMapWidth换算。Photo Picker 替代方案如果你的需求只是让用户选一张照片不需要实时预览和拍照控制那直接用 Photo Picker 就够了——零权限、三行代码、系统级 UI。import{picker}fromkit.CoreFileKit;asyncpickImageFromGallery():Promisestring{constoptionsnewpicker.PhotoSelectOptions();options.MIMETypepicker.PhotoViewMIMETypes.IMAGE_TYPE;options.maxSelectNumber1;constphotoPickernewpicker.PhotoViewPicker();constresult:picker.PhotoSelectResultawaitphotoPicker.select(options);if(result.photoUris.length0){returnresult.photoUris[0];}return;}Photo Picker 不需要任何权限声明系统会自动处理权限流程。选到的 URI 可以直接用image.createImageSource(uri)加载成 PixelMap再做后续裁剪。关键区别Photo Picker 选到的是 URI 而不是文件路径不能直接用fileIo.readTextSync读取。要用createImageSource或fs.openSync打开 URI。完整拍照裁剪页面把 XComponent 预览、拍照、裁剪串起来的完整页面。拍照后进入裁剪模式裁剪完成后保存。EntryComponentstruct CameraCropPage{privatexComponentController:XComponentControllernewXComponentController();StatesurfaceId:string;StatephotoPixelMap:image.PixelMap|undefinedundefined;StateisCropping:booleanfalse;StatecropX:number50;StatecropY:number50;StatecropSize:number200;asyncinitCamera():Promisevoid{if(this.surfaceId){return;}constcameraManagercamera.getCameraManager(getContext(this));constcamerascameraManager.getSupportedCameras();constcameraInputcameraManager.createCameraInput(cameras[0]);awaitcameraInput.open();constcapabilitycameraManager.getSupportedOutputCapability(cameras[0],camera.SceneMode.NORMAL_PHOTO);constpreviewOutputcameraManager.createPreviewOutput(capability.previewProfiles[0],this.surfaceId);constphotoOutputcameraManager.createPhotoOutput(capability.photoProfiles[0]);constsessioncameraManager.createSession(camera.SceneMode.NORMAL_PHOTO);session.beginConfig();session.addInput(cameraInput);session.addOutput(previewOutput);session.addOutput(photoOutput);awaitsession.commitConfig();awaitsession.start();photoOutput.on(photoAvailable,(photo:camera.Photo):void{constaccessorphoto.accessor;accessor.createPixelMap().then((pm:image.PixelMap):void{this.photoPixelMappm;this.isCroppingtrue;});});}asyncdoCapture():Promisevoid{// 触发拍照实际需要保存 photoOutput 引用}asyncconfirmCrop():Promisevoid{if(!this.photoPixelMap){return;}awaitthis.photoPixelMap.crop({x:this.cropX,y:this.cropY,size:{width:this.cropSize,height:this.cropSize},});this.isCroppingfalse;}build(){Column(){if(!this.isCropping){XComponent({id:cameraPreview,type:surface,libraryname:,controller:this.xComponentController,}).onLoad((){this.xComponentController.setXComponentSurfaceRect({surfaceWidth:1920,surfaceHeight:1080});this.surfaceIdthis.xComponentController.getXComponentSurfaceId();this.initCamera();}).width(100%).aspectRatio(16/9)Button(拍照).margin({top:20}).onClick((){this.doCapture();})}else{if(this.photoPixelMap){Image(this.photoPixelMap).width(100%).height(400).objectFit(ImageFit.Contain)}Row({space:20}){Button(确认裁剪).onClick((){this.confirmCrop();})Button(重拍).onClick((){this.photoPixelMapundefined;this.isCroppingfalse;})}.margin({top:20})}}.width(100%).height(100%).padding(20)}}这个页面实现了预览 → 拍照 → 裁剪的完整链路。实际项目中裁剪框的交互会更复杂需要加上拖拽手柄和边界校验但核心流程就是这样。踩坑清单问题原因解决预览黑屏surfaceId 为空就初始化相机在 XComponent.onLoad 回调里拿 surfaceId预览画面拉伸Surface 尺寸和预览分辨率不匹配setXComponentSurfaceRect 与 previewProfile 保持一致capture 无回调未监听 photoAvailable拍照前注册 photoOutput.on(‘photoAvailable’)PixelMap crop 坐标偏移先 scale 再 crop 坐标系变了先裁剪再缩放或按缩放后尺寸换算坐标相机权限被拒未声明 ohos.permission.CAMERAmodule.json5 加 user_grant 权限并动态申请多次拍照内存暴涨photoAvailable 未 off拍照结束后 photoOutput.off(‘photoAvailable’)Image 显示 URI 图片报错Picker 返回 URI 不是文件路径用 createImageSource(uri) 加载裁剪框拖出图片未做边界 clampcropX/cropY 限制在 [0, imgWidth - cropSize]XComponent onLoad 不触发type 写成了 ‘component’相机预览必须 type: ‘surface’前置摄像头画面左右颠倒前置默认镜像预览无需处理如需翻转用 PixelMap.flip