需求是拍摄讲解视频时,后摄记录现场,前摄记录讲解者;做操作演示时,一路对着操作过程,另一路保留人物反应。需求最终需要一张同时包含两个视角的画面,并能把它保存成照片或视频。
lf-dual-camera 的处理方式是:前后摄分别提供图像,原生侧完成合成,预览和文件导出使用同一套布局。页面负责按钮、设置和状态提示,相机采集、图像处理及编码留在 Android 侧。
1. 要实现什么效果
合成画面提供三种布局:画中画、上下分屏、左右分屏。画中画可调整小窗位置和大小;分屏可互换主次摄像头。前摄镜像、圆角、文字和时间水印参与合成,导出的照片与视频也会保留这些设置。
实拍图位:三种布局对照
使用同一组前后摄场景,分别截取画中画、上下分屏、左右分屏,标明主摄和副摄。
图注:同一双摄输入在三种合成布局下的预览效果。
2. 为什么不能直接打开两个相机
手机有前摄和后摄,只能说明两者分别存在,不能说明它们可以同时输出。相机资源还受到系统实现、图像处理通道和输出流配置的约束。依次调用两次打开接口,第二次仍可能失败。
Android 从 API 30 提供并发组合查询。getConcurrentCameraIds() 返回允许并发配置的摄像头 ID 组合;插件从组合中筛选一颗后摄和一颗前摄,不假定 ID 一定是 0 和 1。Android CameraManager 文档 "Android CameraManager 文档")
下面是 CameraCapabilities.kt 中的组合筛选代码,省略了导入和类的其他成员。
fun pairs(): List<List<String>> {
if (Build.VERSION.SDK_INT < 30) return emptyList()
val pairs = ArrayList<List<String>>()
for (combination in manager.concurrentCameraIds.sortedBy {
it.sorted().joinToString(",") }) {
val fronts = combination.sorted().filter {
facing(it) == "front" }
val backs = combination.sorted().filter {
facing(it) == "back" }
for (back in backs) for (front in fronts) pairs.add(listOf(back, front))
}
return pairs.distinct()
}
拿到组合后,还要检查目标画质对应的输入尺寸、帧时长及 H.264 Surface 编码能力。获得权限后,进一步调用 isConcurrentSessionConfigurationSupported() 检查会话配置;正式打开时,再用实际输入 Surface 检查一次。
因此,dualSupported、profiles 和打开结果分别回答不同问题:系统有没有声明前后摄并发组合、当前检测有哪些可用画质、这次打开是否成功。dualSupported: true 不能替代后两项检查。未授权时画质检测采用保守结果,授权后可以重新查询。
画质也不能只看某一颗摄像头能否输出 1080p。双路同时工作时,整组输出配置需要被系统接受,编码器还要支持合成后的尺寸和帧率。单摄支持的分辨率,不能直接当成双摄支持的分辨率。示例从 720p 开始,先确认基本链路,再依据检测结果开放更高画质。
不支持双摄时,页面应显示原因,再让使用者选择前摄或后摄。插件不会悄悄切换模式。这样用户看到的是自己选择的拍摄方式,业务也不会把单摄文件误认为双摄成片。
3. 双摄画面怎样进入页面
页面使用 lf-dual-camera-x 标准组件,组件内部放置 native-view。初始化时,UTS 将 Kotlin 创建的 TextureView 绑定到原生视图元素,Android 渲染的画面由这个载体显示。DCloud native-view 文档

图 1:页面通过 UTS 控制相机会话;两路图像在原生侧合成,再分别用于预览、照片和视频输出。
这里的职责分成三层。.uvue 页面管理交互和显示状态;UTS 对外提供参数类型、权限申请和回调;Kotlin 负责 Camera2 会话、OpenGL 渲染、编码与文件写入。UTS 可以直接调用插件目录里的 Kotlin 类,属于原生混编。DCloud UTS 原生混编说明
组件创建后通过 ready 返回 sessionId,后续操作用它定位相机会话。ready 表示预览载体已经建立,此时摄像头可能还没有打开。双摄打开成功还要等两路输入都至少收到首帧。
页面传递的是模式、画质、布局等控制数据,以及拍摄结果的路径和尺寸。摄像头图像不会逐帧转成 Base64,也不会经由 UTS 回调交给页面再拼接。这样页面交互不必承担连续图像数据的传输和处理。
这个插件在控制接口中使用 JSON 传递纯数据,成功和失败回调留在 UTS 层,不参与序列化。对外统一会话标识,也让拍照、录像和组件事件能够对应到同一个预览实例,避免多个页面之间误操作其他会话。
4. 两路画面怎样合成
4.1 每颗摄像头提供一条输入流
原生侧为每颗摄像头创建一张 OES 外部纹理,再用 SurfaceTexture 关联纹理,并把对应的 Surface 交给 Camera2。摄像头写入新图像后,只标记该输入有更新;读取纹理和绘制统一放到 GL 线程。
渲染循环读取两路最新可用画面,调用 updateTexImage(),同时取得该帧的纹理变换矩阵,然后把两路图像画进同一个 FBO。FBO 可以理解为一块不直接显示在屏幕上的输出画布,其尺寸由打开相机时的画质和方向决定。
合成调度的目标是 30fps。这是配置和调度目标,实际表现取决于设备。两路输入也没有按曝光时间配对,因此这里实现的是并发采集与合成,不能据此声称两颗传感器严格同步。
4.2 布局决定画到哪里,裁剪决定取哪一部分
pipRect 使用完整输出画布的归一化坐标,原点在左上角。假设输出为 720×1280,设置 x=0.60、y=0.10、width=0.30、height=0.30,小窗就在距左侧 432 像素、距顶部 128 像素的位置,区域为 216×384 像素。这组参数用于说明坐标,不是组件默认值。
上下分屏各占画布一半高度,左右分屏各占一半宽度。渲染器先绘制主画面,再绘制副画面,最后叠加水印。以下是 compose() 内部的布局分支节选,变量来自所在类及前面的摄像头选择过程。
if (primary != null) {
if (secondary == null) drawCamera(primary, 0f, 0f, 1f, 1f, 0f)
else when (appearance.optString("layout", "pip")) {
"split-vertical" -> {
drawCamera(primary, 0f, 0f, 1f, .5f, 0f); drawCamera(secondary, 0f, .5f, 1f, .5f, 0f) }
"split-horizontal" -> {
drawCamera(primary, 0f, 0f, .5f, 1f, 0f); drawCamera(secondary, .5f, 0f, .5f, 1f, 0f) }
else -> {
drawCamera(primary, 0f, 0f, 1f, 1f, 0f)
val rect = appearance.optJSONObject("pipRect") ?: JSONObject()
val width = rect.optDouble("width", .3).toFloat().coerceIn(.05f, 1f)
val height = rect.optDouble("height", .225).toFloat().coerceIn(.05f, 1f)
val x = rect.optDouble("x", .67).toFloat().coerceIn(0f, 1f - width)
val y = rect.optDouble("y", .03).toFloat().coerceIn(0f, 1f - height)
drawCamera(secondary, x, y, width, height,
appearance.optDouble("radius", .025).toFloat().coerceIn(0f, .5f) * min(output.width, output.height))
}
}
}
drawWatermark()
摄像头输入与目标区域的宽高比通常不同。插件在每个区域内做等比居中裁剪:保留物体比例,裁掉超出区域的视野。把整个输入直接缩放到区域宽高,会把人物或圆形物体压扁。

图 2:小窗和分屏的位置以输出画布为准;摄像头图像在各自区域内居中裁剪,完整合成画面在预览容器内等比显示。
还要区分两种适配。摄像头进入合成区域时采用裁剪;完整合成画面进入页面容器时采用等比完整显示,容器比例不同可能出现黑边。接入时可以按下面三项区分设置对象。
| 设置 | 影响对象 | 是否改变输出文件宽高 |
|---|---|---|
| 组件样式宽高 | 页面里的预览区域 | 否 |
layout、pipRect |
合成画面中的区域和位置 | 否 |
profile、orientation |
合成输出尺寸与方向 | 是 |
点按对焦同样要使用这套坐标关系。触点先从预览区域换算到输出画布,再确定落在哪颗摄像头的区域内,随后逆向处理裁剪、旋转和镜像,映射到传感器测光区域。直接把屏幕坐标除以页面宽高传给相机,在分屏或小窗里就可能对错位置。
5. 在 uni-app x 中接入
将插件放入工程的 uni_modules/lf-dual-camera,新增 .uvue 页面,并在 pages.json 注册。示例按从业务首页进入的二级页面使用,后面的返回处理依赖这一层页面栈。页面固定使用 720p 竖屏输出,先检测能力,再由按钮选择双摄、前摄或后摄。
<template>
<view>
<!-- #ifdef APP-ANDROID -->
<lf-dual-camera-x style="width:100%;height:360px"
@ready="onCameraReady" @statechange="onCameraState"
@error="onCameraError" />
<text>{
{
notice }}</text>
<button :disabled="detecting || opening || closing || opened" @click="detect">检测设备</button>
<button :disabled="!canOpen || !dualSupported" @click="openPreview('dual')">打开双摄</button>
<button :disabled="!canOpen" @click="openPreview('back')">打开后摄</button>
<button :disabled="!canOpen" @click="openPreview('front')">打开前摄</button>
<button :disabled="closing || (!opened && !opening)" @click="closePreview">关闭相机</button>
<!-- #endif -->
<!-- #ifndef APP-ANDROID -->
<text>此示例仅支持 Android App。</text>
<!-- #endif -->
</view>
</template>
<script setup lang="uts">
import LfDualCameraX from '@/uni_modules/lf-dual-camera/components/lf-dual-camera-x/lf-dual-camera-x.uvue'
import {
getCapabilities, openCamera, closeCamera } from '@/uni_modules/lf-dual-camera'
import type {
LfCameraOptions, LfCameraCapabilitiesOptions, LfCameraError } from '@/uni_modules/lf-dual-camera'
const sessionId = ref('')
const ready = ref(false)
const dualSupported = ref(false)
const detecting = ref(false)
const opening = ref(false)
const closing = ref(false)
const opened = ref(false)
const notice = ref('先检测设备,再选择拍摄模式。')
let pageVisible = true
let disposed = false
let closeRequested = false
const canOpen = computed((): boolean => sessionId.value.length > 0 && ready.value &&
!detecting.value && !opening.value && !closing.value && !opened.value)
function showError(error: LfCameraError): void {
if (!disposed) notice.value = error.code + ':' + error.message
}
function closeNative(id: string): void {
if (id.length == 0 || closing.value) return
if (!disposed) closing.value = true
const options: LfCameraOptions = {
sessionId: id,
success: (_) => {
if (!disposed) opened.value = false },
fail: (error) => showError(error),
complete: () => {
if (!disposed) closing.value = false }
}
closeCamera(options)
}
function closePreview(): void {
closeRequested = true
closeNative(sessionId.value)
}
function onCameraReady(event: UniNativeViewEvent): void {
const id = ((event.detail as UTSJSONObject)['sessionId'] as string | null) ?? ''
if (disposed) {
closeNative(id); return }
sessionId.value = id
}
function onCameraState(event: UniNativeViewEvent): void {
if (disposed) return
const state = ((event.detail as UTSJSONObject)['state'] as string | null) ?? ''
if (state == 'previewing') {
if (closeRequested || !pageVisible) closePreview()
else opened.value = true
}
if (state == 'closed' || state == 'error') opened.value = false
}
function onCameraError(event: UniNativeViewEvent): void {
const detail = event.detail as UTSJSONObject
showError({
code: (detail['code'] as string | null) ?? 'UNKNOWN',
message: (detail['message'] as string | null) ?? '相机暂不可用' })
}
function detect(): void {
if (disposed || detecting.value || opened.value || opening.value || closing.value) return
// #ifdef APP-ANDROID
ready.value = false
detecting.value = true
const options: LfCameraCapabilitiesOptions = {
success: (data) => {
if (disposed) return
dualSupported.value = data.dualSupported
ready.value = true
notice.value = data.dualSupported ? '系统提供双摄组合,仍需实际打开检查。' : data.reason
},
fail: (error) => showError(error),
complete: () => {
if (!disposed) detecting.value = false }
}
getCapabilities(options)
// #endif
}
function openPreview(mode: string): void {
if (disposed || !pageVisible || !canOpen.value || (mode == 'dual' && !dualSupported.value)) return
const id = sessionId.value
closeRequested = false
opening.value = true
const options: LfCameraOptions = {
sessionId: id, mode, profile: '720p', orientation: 'portrait',
success: (_) => {
if (disposed || closeRequested || !pageVisible) {
closeNative(id); return }
opened.value = true
notice.value = '预览已打开:' + mode
},
fail: (error) => showError(error),
complete: () => {
if (!disposed) opening.value = false }
}
openCamera(options)
}
onShow(() => {
pageVisible = true })
onHide(() => {
pageVisible = false; closePreview() })
onUnload(() => {
pageVisible = false; closePreview(); disposed = true })
</script>
代码把组件就绪、能力检测和相机打开分开处理。只有取得会话且能力检测成功,才允许发起打开;双摄按钮还需要 dualSupported 为真。实际打开失败时仍显示错误,不因检测通过而忽略失败回调。
页面隐藏后关闭相机,返回时需要手动重新打开。如果授权弹窗或打开请求尚未结束,页面就已隐藏,迟到的打开成功回调也会触发关闭,避免后台继续占用相机。
权限按操作申请:打开预览需要相机权限,有声录像再申请麦克风权限,无声录像不采集麦克风。拒绝麦克风权限后,应说明原因并由使用者明确选择无声模式。Android Manifest 中的权限声明仍需随原生配置合并,不能只检查页面里的请求代码。
需要改变布局时,传入组件的 layout、mainCamera 和 pipRect 即可。绑定了自定义 pipRect 并允许拖动时,应通过 pipchange 回写位置,避免下一次属性更新又恢复旧值。完整 Demo 中的缩放、对焦、补光按钮,还需根据检测到的摄像头能力决定是否开放。
6. 拍照、录像与保存相册
6.1 照片从合成画布读取
拍照时,渲染器先应用当前布局,随后通过 glReadPixels() 从共享 FBO 读取 RGBA 像素。读取后交给独立线程,完成上下行翻转、颜色通道转换和 JPEG 编码,处理 OpenGL 像素与 Android Bitmap 的存储差异。
makeCurrent(pbuffer)
// Render pending appearance changes before reading the shared composite.
compose()
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, framebuffer)
val pixels = ByteBuffer.allocateDirect(output.width * output.height * 4).order(ByteOrder.nativeOrder())
GLES20.glReadPixels(0, 0, output.width, output.height, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, pixels)
checkGl("Read photo")
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0)
这样得到的是当前 720p 或 1080p 合成画布的快照,包含布局、镜像和水印,并非两颗传感器各自的全像素照片。这里存在 GPU 到 CPU 的像素回读,也有 Bitmap 编码,不能把整个拍照过程称为零拷贝。
6.2 视频把同一张合成纹理送入编码器
录像使用 MediaCodec 的 Surface 输入。GL 线程切换到编码窗口,将合成纹理再画一次,设置视频时间戳并提交;编码线程取出 H.264 数据,由 MediaMuxer 写入 MP4。页面按钮、对焦框等界面元素没有进入合成画布,因此不会被录进成片。
val active = recorder
if (active != null && encoderEgl != EGL14.EGL_NO_SURFACE) {
makeCurrent(encoderEgl)
GLES20.glViewport(0, 0, output.width, output.height)
drawTexture(compositeTexture, normalUv, false)
EGLExt.eglPresentationTimeANDROID(display, encoderEgl, max(0L, System.nanoTime() - active.startedAtNs))
if (!EGL14.eglSwapBuffers(display, encoderEgl)) throw IllegalStateException("Video surface swap failed")
}
有声录像另用 AudioRecord 采集 44.1kHz 单声道 PCM,编码为 AAC。视频时间戳相对统一的单调时钟起点计算;音频优先通过 AudioTimestamp 映射到同一起点,无法取得时按累计采样帧数计算。每条轨道写入时还要保证时间戳递增。这是时间轴的处理方式,长时间音画同步仍需通过真机录制检查。
稳定录制阶段允许拍照;录像启动和停止阶段不允许。录制时可以调整布局、镜像和水印,输出方向、画质、单双摄模式和额外旋转则需要先停录。
停止按钮也不是直接关闭文件。原生侧先结束编码输入,继续取出尚未输出的压缩数据,等音视频轨道收尾并完成 MP4 封装,才返回文件结果。按钮应在这段时间保持停止中状态,避免连续点击启动下一次录制,或者把未完成文件交给播放器。
6.3 生成文件与保存相册分开处理
takePhoto 和 stopRecording 成功后得到应用私有文件。需要出现在系统相册中,还要调用 saveToAlbum。Android 10 及以上通过 MediaStore 写入,使用 IS_PENDING 标记未完成文件,写完后再发布;Android 6–9 保存相册时需要存储权限。Android 共享媒体存储说明
在第 5 节页面中加入拍摄功能,先把这组 API 补到原来的导入区:
import {
takePhoto, startRecording, stopRecording, saveToAlbum } from '@/uni_modules/lf-dual-camera'
将下面代码从 const recordStage 开始,整体替换原来的 closePreview 函数,其他函数和生命周期保留。这个位置能保证相关局部函数在首次使用前已经声明。
const recordStage = ref('idle') // idle / starting / recording / stopping
const photoBusy = ref(false)
const failedPaths = ref<string[]>([])
const handledPaths: string[] = []
const savingPaths: string[] = []
let stopCallbacks: (() => void)[] = []
let stopRequested = false
let lastStopFailed = false
function savePath(path: string): void {
if (path.length == 0 || savingPaths.includes(path)) return
savingPaths.push(path)
saveToAlbum({
path,
success: (_) => {
if (!disposed) notice.value = '已保存到系统相册。' },
fail: (error) => {
if (!disposed) {
if (!failedPaths.value.includes(path)) failedPaths.value.push(path)
showError(error)
}
console.log('相册保存失败,原文件保留:', path)
},
complete: () => {
const index = savingPaths.indexOf(path)
if (index >= 0) savingPaths.splice(index, 1)
}
})
}
function acceptFile(path: string): void {
if (path.length == 0 || handledPaths.includes(path)) return
handledPaths.push(path) // 两个完成入口共用此处,先登记再异步保存。
savePath(path)
}
function retryFailed(): void {
const paths = failedPaths.value.slice()
failedPaths.value = []
paths.forEach((path: string) => savePath(path)) // 手动重试不走 handledPaths。
}
function capturePhoto(): void {
if (disposed || !pageVisible || !opened.value || closeRequested || photoBusy.value ||
recordStage.value == 'starting' || recordStage.value == 'stopping') return
photoBusy.value = true
takePhoto({
sessionId: sessionId.value,
success: (file) => acceptFile(file.path), fail: (error) => showError(error),
complete: () => {
if (!disposed) photoBusy.value = false }
})
}
function flushStopCallbacks(): void {
const callbacks = stopCallbacks
stopCallbacks = []
callbacks.forEach((done: () => void) => done())
}
function finishRecording(done: (() => void) | null = null): void {
if (done != null) stopCallbacks.push(done)
if (stopRequested || recordStage.value == 'starting') return
if (recordStage.value == 'idle') {
flushStopCallbacks(); return }
stopRequested = true
lastStopFailed = false
if (!disposed) recordStage.value = 'stopping'
stopRecording({
sessionId: sessionId.value,
success: (file) => {
lastStopFailed = false; acceptFile(file.path) },
fail: (error) => {
lastStopFailed = true; showError(error) },
complete: () => {
stopRequested = false
if (!disposed) recordStage.value = 'idle'
flushStopCallbacks()
}
})
}
function beginRecording(): void {
if (disposed || !pageVisible || !opened.value || closeRequested || photoBusy.value || recordStage.value != 'idle') return
recordStage.value = 'starting'
lastStopFailed = false
const id = sessionId.value
startRecording({
sessionId: id, audio: true,
success: (_) => {
if (disposed) {
stopRecording({
sessionId: id,
success: (file) => {
lastStopFailed = false; acceptFile(file.path) },
fail: (error) => {
lastStopFailed = true; showError(error) },
complete: () => flushStopCallbacks() })
return
}
recordStage.value = 'recording'
if (!pageVisible || closeRequested || stopCallbacks.length > 0) finishRecording()
},
fail: (error) => {
lastStopFailed = true
if (!disposed) recordStage.value = 'idle'
showError(error)
flushStopCallbacks()
}
})
}
function onRecordComplete(event: UniNativeViewEvent): void {
const detail = event.detail as UTSJSONObject
const result = (detail['result'] as UTSJSONObject | null) ?? detail
acceptFile((result['path'] as string | null) ?? '')
if (!disposed && !stopRequested) recordStage.value = 'idle'
}
function onRecordingState(event: UniNativeViewEvent): void {
if (disposed || stopRequested || recordStage.value == 'starting') return
const state = ((event.detail as UTSJSONObject)['state'] as string | null) ?? ''
if (state == 'idle') recordStage.value = 'idle'
}
// 用本函数替换第 5 节的 closePreview;不要保留两个同名函数。
function closePreview(): void {
closeRequested = true
const id = sessionId.value
finishRecording(() => closeNative(id))
}
// 主动返回时,先接收停录结果;相册保存仍可能在异步执行。
let allowBack = false
let leaving = false
onBackPress((_) : boolean => {
if (allowBack) return false
if (leaving) return true
if (recordStage.value == 'idle') return false
leaving = true
finishRecording(() => {
if (lastStopFailed) {
leaving = false; return }
allowBack = true
uni.navigateBack()
})
return true
})
把原来的组件标签替换为下面的标签,增加录制事件;四个拍摄按钮放在原按钮之后,仍保留在 Android 条件编译块内。
<lf-dual-camera-x style="width:100%;height:360px"
@ready="onCameraReady" @statechange="onCameraState"
@error="onCameraError" @recordcomplete="onRecordComplete"
@recordingchange="onRecordingState" />
<button :disabled="!opened || photoBusy || recordStage == 'starting' || recordStage == 'stopping'"
@click="capturePhoto">拍照</button>
<button :disabled="!opened || photoBusy || recordStage != 'idle'"
@click="beginRecording">开始有声录像</button>
<button :disabled="recordStage != 'recording'" @click="finishRecording()">停止录像</button>
<button :disabled="failedPaths.length == 0" @click="retryFailed">重试保存相册</button>
停止录像的成功回调与 recordcomplete 事件可能报告同一个文件,因此自动保存入口按 path 去重。保存失败时保留原文件,手动重试应绕过“已经自动处理过”的判断;同时阻止正在保存的路径被重复提交。
示例在主动返回时先等待停录结果,失败则留页显示错误;切后台或卸载时,无论停录成功与否,都继续清理相机资源。closeCamera.success 只代表关闭流程完成,不能替代成片结果。是否还要等待相册保存,由页面的退出流程明确决定,不能在保存请求刚发出时就提示“已保存”。
实拍图位:导出照片与视频帧
拍摄带水印的同一场景,展示 JPEG 和 MP4 的对应画面。
图注:预览、照片和录像使用相同的合成布局,页面操作栏不写入成片。
7. 方向、比例和退出问题怎么处理
7.1 不要把传感器方向重复应用
相机画面横转时,直接补一个 90° 旋转可能只修好当前机型。Camera2 输入的 SurfaceTexture 矩阵已经包含生产者侧的方向等变换,再叠加完整传感器旋转,可能出现重复处理。
当前实现先计算最终需要的裁剪、镜像和方向映射,再抵消预计已存在于输入中的变换,形成附加 UV。着色器仍乘每帧真实的 SurfaceTexture 矩阵,保留生产者的裁剪和边缘修正。这个处理参考了 CameraX SurfaceOutputImpl 的实现。
用矩阵关系表示,附加变换为 predictedST⁻¹ × desired。其中 desired 包含目标方向、裁剪和镜像;predictedST 是预计已经存在于相机输入中的变换。CameraMath.textureCoordinates() 抵消预计方向的关键片段如下,sensor 是前面计算出的目标采样点。
// Invert predictedST = verticalFlip * rotate(sensor) * mirror(front).
val u = sensor.x
val v = 1.0 - sensor.y
val unrotated = when (((input.sensorOrientation % 360) + 360) % 360) {
90 -> Point(v, 1.0 - u)
180 -> Point(1.0 - u, 1.0 - v)
270 -> Point(1.0 - v, u)
else -> Point(u, v)
}
coordinates[i * 2] = (if (front) 1.0 - unrotated.x else unrotated.x).toFloat()
coordinates[i * 2 + 1] = unrotated.y.toFloat()
顶点着色器随后仍执行 uTransform * vec4(aTexCoord, 0.0, 1.0),其中 uTransform 来自当前帧真实的 SurfaceTexture 矩阵。
检查方向时可分别让前后摄拍摄正立文字,检查比例时使用圆形物体。前摄镜像开启后,文字左右翻转是镜像结果;文字横倒、圆形变椭圆,则需要继续检查方向矩阵或裁剪计算。
实拍图位:方向与比例检查
使用文字卡和圆形物体,覆盖前后摄、镜像开关与至少一种分屏。
图注:通过文字方向与圆形轮廓检查旋转、镜像和等比裁剪。未取得修复前截图时,不制作前后对比。
7.2 关闭相机需要等待原生回调
离开页面或返回上一页时,Camera2 的打开、录像收尾和照片写入可能仍在执行。原生层通过 generation 标识失效的打开流程;关闭时先结束录像、等待未完成照片,再关闭 CaptureSession 和 CameraDevice,确认设备关闭后释放输入 Surface。
页面关闭相机与组件销毁也有区别。前者保留预览载体,方便再次打开;组件卸载才释放绑定和会话。Vapor 中不把原生元素保存后再传回桥接入口,而是传字符串 bindingId,由原生侧查找并释放绑定,避免对象跨运行环境后的类型变化。
业务页面使用公开的关闭接口即可,不需要复制组件内部的绑定清理代码。当前不支持后台录像,切后台、返回、拒绝权限和相机被占用,都应列入实际设备的验收场景。