相机页面最容易出现一句让人误判的话:权限已允许。
看到它以后,很多人会自然把后半句补上:“那预览应该起来了。”我第一次排这个问题时也是如此。页面已经获得ohos.permission.CAMERA,按钮也没有灰掉,右侧却还是一块黑色区域,状态停在“等待 Surface”或者“设备未查询”。
那一刻最容易做的,是反复申请权限,或者把相机权限当成唯一排查入口。后来我才把流程拆开:权限只是进门证,预览真正跑起来还要经过 Surface 就绪、设备发现、输出能力查询、输入输出绑定和会话启动。门开了,不等于里面每一盏灯都已经亮。
我当时犯的第一个错误
我把相机预览理解成一条直线:点击按钮,申请权限,出现画面。可现有页面并不是这样设计的。它有明确的运行阶段:
type CameraPhase = 'waiting_surface' | 'requesting_permission' | 'querying_device' | 'starting_preview' | 'previewing' | 'unavailable' | 'failed' | 'released'; @State phase: CameraPhase = 'waiting_surface'; @State surfaceReady: boolean = false; @State permissionState: string = '未请求'; @State previewState: string = '等待 Surface';这几个状态摆在一起,就已经否定了“权限通过就等于预览成功”的想法。至少在点击前,XComponent必须先完成加载;否则应用没有一个可交给相机输出的 Surface。没有 Surface,连创建预览输出都没有意义。
这张流程图的意义不在于背下来,而是在黑屏出现时先找它卡在哪一格。不同格子的处理完全不同:Surface 未就绪不能靠重复授权解决;没有后摄设备也不能靠重建 UI 解决;会话启动失败则需要看运行错误和资源释放路径。
黑屏那次,我先查错了地方
当时页面已经显示“已授予”,我就不断点击启动按钮。没有新画面,我以为系统把权限结果缓存错了。实际上,启动函数一开始就有一个前置判断:
private async startCameraSession(): Promise<void> { if (!this.surfaceReady) { this.previewState = 'Surface 尚未就绪'; this.markRuntime('未启动相机:XComponent Surface 未就绪'); return; } if (this.phase === 'previewing' || this.phase === 'starting_preview') { return; } // 后面才会申请权限并创建会话。 }它把“页面还没准备好”留成了一个可解释的分支。这个分支很重要,因为如果在 Surface 未就绪时仍然硬往下执行,后面失败的位置会变得含糊。你可能看到的是创建输出失败、会话失败,最后却误以为是相机 API 不稳定。
我后来把排查动作改得很简单:第一次进入页面不急着点按钮,先看事件栏是否出现XComponent Surface 已就绪,可请求真实后摄预览。如果没有,先检查页面生命周期和 XComponent;如果有,再开始看权限与设备。这样做看似比直接点一次多了一步,却会省掉很多无效猜测。
权限之后,还有两个真实查询
用户同意授权后,函数把阶段切到querying_device,再从CameraManager找后摄设备。这里不会假设任何机器都有后摄,更不会假设后摄一定提供普通视频预览 Profile。
const manager = camera.getCameraManager(getContext(this)); const device = this.selectBackCamera(manager.getSupportedCameras()); if (device === undefined) { this.phase = 'unavailable'; this.deviceState = '未发现后摄设备'; this.previewState = '设备不支持后摄预览'; return; } const capability = manager.getSupportedOutputCapability(device, camera.SceneMode.NORMAL_VIDEO); const previewProfile = capability.previewProfiles.length > 0 ? capability.previewProfiles[0] : undefined;我很喜欢这里的写法,它没有把“查询失败”压成一个笼统的“不支持”。找不到后摄和找不到previewProfile是两种不同问题。前者说明目标设备不在支持列表里,后者说明设备虽然存在,但这个场景没有可用的视频预览输出。两者后续的处理方式也不一样。
以前我会把黑屏统称为“没有预览”,现在更愿意把它写成具体句子:Surface 未就绪、权限被拒绝、未发现后摄、没有预览 Profile、会话启动失败,或者会话已经启动。每一句话都比“相机异常”更接近下一步。
会话启动时,别忽略资源的进出
真正进入预览前,页面会创建CameraInput、PreviewOutput和VideoSession,依次加到会话里,再提交配置、设置对焦与自动构图能力、最后调用start()。如果其中任何一步抛错,页面会把阶段置为failed,随后释放已经创建的资源。
这不是为了把代码写得复杂,而是避免下一次启动继承上次失败留下的半截资源。相机这类独占能力很像一个临时工作台:工具没有收干净,下一次拿起新工具时往往先被旧东西绊住。
这里有一条很实用的原则:不要在catch里只写“启动失败”。至少要让页面同时保留阶段、预览状态、最后事件和错误摘要。现有 Demo 中的fail()就做了这件事。测试人员看到“运行失败”时,可以继续看具体是哪一段调用出了问题,而不是从头重跑所有步骤。
我的测试顺序
这页不适合用“允许权限后应该看到相机”这种一句话做测试。它会把多个阶段压成一个结果,失败时没有方向。我现在按下面的顺序测:
这里的最后一步故意写成“另行观察”。预览会话启动只证明当前页面的后摄预览链路已经走到previewing,不证明连续自动对焦已经准确完成,也不证明自动构图在当前系统控制中心已经启用。后两个问题需要单独看能力查询和实际系统反馈,不能顺手搭车。
我会把状态字段当作下一步的路标
这页里最有用的不是某一个“成功”字样,而是几个状态组合起来后的含义。phase=waiting_surface配合previewState=等待 Surface,说明还不该把排查带到权限;phase=querying_device则说明授权已经过去,页面正在读取真实设备能力。若deviceState写着未发现后摄,继续反复点启动按钮不会创造出硬件;若它已经写出后摄 ID 和尺寸,却仍停在starting_preview或进入failed,才该把注意力放到输入、输出、提交配置和异常文本上。
我复测时会先记录这一组状态,再按停止、重新进入页面、等待 Surface、重新启动的顺序走一遍。停止操作后应能看到会话已释放;再次启动时,页面重新查询设备并创建新的会话,而不是沿用上一轮的对象。这个回归动作不能证明实际成像效果,却能排除一个很常见的误会:第一次失败后留下的输入或会话资源,让第二次失败看起来像同一条错误。
还有一点很容易忽略:previewing是会话已启动的页面状态,不是一张由页面生成的“预览成功截图”。右侧 XComponent 只承载实际 Camera 输出,黑色背景本身不能被解释成有画面或没画面。需要评价真实预览时,我会在目标真机上记录时间、设备、页面状态和屏幕结果;当前代码没有加载静态图片替代,因此也不该把页面的占位背景说成相机画面。
这次问题留下的三个检查项
我后来把黑屏按阶段记下来
真正排这类问题时,我不会只留一张黑色预览区的截图。那张图只能说明当时屏幕上没有我期待的画面,说明不了流程走到了哪里。更有用的记录至少要同时写下阶段、权限、设备、预览状态、最近事件和错误文本。比如waiting_surface时,按钮被禁用或提示 Surface 尚未就绪是合理结果;这时反复要求用户去设置页开权限,只会把人带离问题。等onLoad触发后,surfaceReady变为 true,页面才具备进入下一步的条件。
如果授权被拒绝,代码会把权限写成被拒绝,并把预览停在未启动的位置。这里我会记录用户是否主动拒绝、是否有授权请求异常,但不会把它和设备问题合并。授权已授予后,getSupportedCameras()返回的列表才成为下一处判断依据。页面专门寻找后摄,不是任意一个摄像头都能替代;模拟器、无后摄的设备,或某些设备模式下的可见列表不同,都可能走到未发现后摄这一支。这个分支的后果是流程结束,而不是再拿默认对象继续创建输入。
找到设备也还没有完成。代码继续在普通视频场景查询输出能力,并从previewProfiles中取第一个候选项。候选项为空时,页面会明确写出设备没有提供视频预览输出能力。它和“后摄不存在”相似,都是不可用,但对排查来说不是同一句话:前者要确认设备枚举和目标摄像头,后者要确认该场景的输出能力。把两者分开,后续拿到新的设备日志时才不会把硬件发现、场景能力和会话配置混在一起。
进入starting_preview后,我会特别留意错误发生在打开输入、创建输出、配置会话还是启动会话。源码把输入、输出、会话保存为成员,异常后会进入释放逻辑;因此页面的 failed 不是一句泛泛的“相机坏了”,而是需要和错误摘要一起读。回归时我会故意先启动一次,再点停止并离开页面,确认释放后的状态能回到可再次启动的路径。这个检查只验证资源生命周期是否收口,不表示第二次预览一定能给出同样的实际画面。
还有一个容易被忽视的时间顺序:页面刚出现时 Surface 的加载与用户点击并不保证谁先发生。按钮通过surfaceReady控制可用性,是为了把这次竞态变成可见状态,而不是让用户在尚无承载面的时刻提交一个注定无法绑定的输出。看到按钮不可点时,我会先等待事件文本更新;看到可点后再点一次即可。把等待写进测试步骤,比“偶发黑屏,重试即可”更能让后来的人复现和判断。
用最小变量做一次回归
我的回归不会同时换镜头、切后台、改权限和重装应用。先在同一台设备进入页面,等待 Surface 状态明确,再启动一次;若失败,保存本轮字段后停止并重新进入;若成功,也先停止并重新启动,观察阶段是否能从 released 回到 querying_device 与 starting_preview。只有这条页面内路径稳定,才会额外测试拒绝授权、无后摄或能力为空等环境分支。这样每一次现象都只有一个主要变量,日志里的差异才有解释空间。
如果要把结果交给别人复查,我会把“没有启动”换成源码里的具体终点。例如“Surface 尚未就绪,未进入权限请求”“权限被拒绝,未查询设备”“未发现后摄,未创建会话”“没有预览 Profile,未绑定输出”“会话启动抛错,已进入释放”。这些句子不会增加相机能力,却准确划出代码已经做过和没有做过的事情。接手的人看到后,不需要猜我是在描述视觉黑屏、按钮不可用,还是某个异步调用失败。
同时我会保留错误发生前最后一次状态,不只抄异常消息。异常消息可能随运行环境变化,阶段和设备字段却让它有上下文。比如同样是启动失败,已经查到某个后摄和尺寸,与设备列表为空,排查顺序完全不同。页面已有lastEvent、lastUpdatedAt和errorMessage,正好可以把这层上下文留在同一轮记录里。它们帮助解释页面行为,但不把实际预览画面的质量或是否成像替页面作保证。
最后还要区分“停止”与“失败”。用户主动停止时,代码会尝试停止会话、释放预览输出并关闭输入,随后把阶段写成 released;异常路径则保留 failed 与错误。两者都可能让右侧看起来没有画面,却代表不同的后续动作。前者可以重新启动验证资源是否释放干净,后者应先保存错误与前序状态。把这点写清楚后,页面结束预览不再一律被叫作黑屏故障。
交接给同事时,我会要求先复述当前阶段再操作。因为相机页面里的同一个启动按钮,在等待 Surface、授权中、查询设备、运行中和已释放这些阶段背后含义不同。先读状态再点击,可以避免在运行中重复启动,也避免把释放后的正常空白误报为异常。这个习惯很小,却能让每次问题描述从一开始就落在正确分支上。
若现场无法继续测试,我至少会留下设备类型、页面进入时间、最后阶段和错误原文。没有这些信息的“预览起不来”,后续只能从头重复每个分支;有了它们,即使没有真实画面材料,也能先判断本轮流程在哪一步结束,决定下一次应补生命周期、权限、设备能力还是会话错误的观察。
回归完成后我还会从页面退出再重新进入一次。这个动作不评价相机画面,只确认aboutToDisappear的释放路径没有让下一次 Surface、设备查询或会话创建停在旧对象上。若第二次的状态从等待 Surface 开始并重新按顺序变化,就说明页面自身的生命周期记录是连贯的;若出现异常,则应将两次的时间和错误并列,而不是只保留最后一条。
- 权限前,Surface 是否已经 ready?
- 授权后,设备和
previewProfile是否真的存在? - 会话失败时,页面是否保留阶段和错误,并释放已创建资源?
相机预览不该被写成一扇只需要权限钥匙的门。它更像一条走廊,门禁只是第一道门,后面还有设备、输出、会话和资源回收。把这些门逐一道出,黑屏就不再是一个让人抓不住的结果,而是一条可以走回去的路径。
本文只依据现有页面的权限、设备、输出能力和VideoSession状态撰写。相机实际画面、成像质量以及不同设备的表现,仍需在对应 API 24 真机环境中单独记录。