ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AR-Android开发实战:从环境搭建到性能调优与避坑指南

AR-Android开发实战:从环境搭建到性能调优与避坑指南

简介:AR-Android 是一套面向 Android 平台的增强现实开发框架示例工程,专为希望快速构建 AR 应用的移动开发者设计。项目围绕增强现实基础、追踪技术、图像处理、3D 渲染与用户交互等核心模块展开,涵盖特征点追踪、标记识别与 SLAM 定位等实现思路,适用于教育虚拟实验、AR 导航、零售试穿等场景。压缩包共包含 49 个文件,其中 27 个 asset 文件保存 3D 模型、材质与场景配置,14 个 meta 文件用于 Unity 资源元数据,2 个 unity 场景文件定义 AR 交互流程,另有项目级 asset 配置和 manifest.json 等工程文件,并附带可直接安装的 Test.apk 演示包,整体大小 17.68MB。目前已有 189 人浏览学习。解压即可获得完整 Unity 工程目录,包括 Assets、ProjectSettings、Packages 等,便于开发者对照场景结构、脚本与项目设置,理解 AR 应用从素材导入到打包发布的完整链路,也可直接基于该项目二次开发,快速验证 AR 功能。

1. AR-Android 是什么:一句话定位,再决定要不要投入

AR-Android 这个关键词,指向的是在 Android 设备上把虚拟内容叠加到真实画面的整套工程方案——涉及相机采集、传感器融合、3D 渲染和机型适配,不是加一个依赖包就能出效果的小功能。它解决的实际问题是:用户举起手机,摄像头画面里能稳定地出现一个随手机移动而保持空间位置的虚拟物体。这篇文章从 ARCore 入手,把环境搭建、最小可运行实现、关键参数和排查路径全部过一遍。适合正打算启动 AR 功能但没定技术方案的团队,也适合已经在开发中被稳定性问题磨到怀疑人生的开发者。

2. 搭建 AR-Android 工程:先把设备、依赖和调试条件定下来

2.1 设备能力:支持清单是第一道门槛,不是一道能绕过的墙

AR-Android 开发的第一步是确定运行设备。增强现实依赖摄像头画面与惯性传感器数据的实时融合,这个能力不是所有 Android 设备都具备。交付某个模拟项目X 时,一开始只写了业务逻辑、没做设备检测,测试人员拿一台旧机型安装后应用启动直接闪退,后来补上这段检查,整个链路才走通。

fun isArSupported(context: Context): Boolean { return try { val session = Session(context) val supported = session.isSupported session.close() supported } catch (e: UnavailableException) { false } }

这段代码做的事是:创建会话、查询支持状态、主动释放会话。容易漏掉的是后半句session.close(),在应用启动路径里多次创建会话而不关闭,会累积底层资源,严重点会让后续启动变慢。还有一个小陷阱:isSupported在部分设备上要等摄像头真正打开后才返回可靠结果,所以更稳的做法是先用 PackageManager 查FEATURE_CAMERA_AR特性,再用会话结果兜底。两者结果不一致的场景并不少见,最终判断以真机表现为准。

提示:Session 的所有调用必须在主线程执行,在子线程创建会直接抛 RuntimeException。

设备检查通过后,下一个决定影响整个节奏的是渲染方案选型。常见做法有三类:一是直接用底层图形 API 写渲染循环,控制力最强但工作量最大,模型加载、着色器、相机纹理都要自己处理;二是用封装好的 Android AR 渲染库,几分钟能跑通官方示例,适合产品原型和中小型项目;三是接入通用 3D 引擎的移动端版本,适合项目后期要做复杂交互或跨平台。我的建议是:团队没有专职图形程序员时,不要尝试第一种。封装库固然有黑匣子的成分,但它的示例代码和排错手段都更成熟,出了问题至少搜得到同类场景。

2.2 依赖与 manifest 配置:五个不能漏的声明

AR-Android 工程的 manifest 有一组固定配置项。漏掉任何一个,行为都是「安装正常、权限都给了,但一开相机就崩」。

<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-feature android:name="android.hardware.camera.ar" android:required="true" /> <application> <meta-data android:name="com.ar.core.VERSION" android:value="1.0" /> </application> </manifest>

CAMERA 权限不用解释,没有它整个应用都不成立。INTERNET 和 ACCESS_NETWORK_STATE 是为远程模型资源和会话调试准备的,就算第一版全部用本地模型,我也建议先加上,否则后期要排查网络问题时还得回来加权限、重新出包。uses-feature的required=true表示不支持 AR 的设备在应用市场上直接搜不到这个应用,属于硬性过滤,对工具类 AR 应用是合适的;如果做的是普通应用里的一个辅助 AR 功能,required要改成 false,并在代码里做降级处理。meta-data 那一项是 ARCore 运行时读取版本声明的地方,漏掉它的典型报错五花八门,大部分指向「启动会话失败」。

依赖方面,工程需要两个基础模块:运行时库和渲染辅助库。运行时库提供平面检测、锚点、光照估计这些能力;渲染辅助库负责把 3D 模型加载进场景并绘制到屏幕上。选型时不要只看 star 数,要看最后提交时间和示例的完整性。一个维护停顿的项目,遇到编译问题基本只能自己啃源码。

2.3 模拟器和真机的差距:两个只在真机复现的 bug

模拟器上跑通 AR 场景不代表真机能跑。摄像头数据、陀螺仪频率、加速度计时序在模拟器里全是仿真出来的,最典型的问题有两个。

第一个是平面检测在模拟器里秒成功,真机上却迟迟检测不到平面。模拟器的平面数据是预设好的,环境光照也恒定,而真机在弱光、重复纹理的地面上几乎无法收敛。第二个是旋转姿态在模拟器里平滑得可怕,真机上手一抖,模型就开始跳。日常开发中,模拟器只适合验证权限流程和 UI 布局,算法表现和渲染效果必须用真机调试。血泪经验是至少备两部不同品牌的中端机型,最好一部高通平台一部非高通平台,能覆盖绝大多数用户的实际硬件。单价高的旗舰机型性能好、传感器豪华,但恰恰掩盖了代码里很多性能问题。

3. 让 AR 物体出现在摄像头画面里:最小可运行实现

3.1 初始化会话:顺序不对,画面就是不亮

AR-Android 应用启动后的第一个动作是初始化 AR 会话。这一步的代码量不大,但调用顺序直接影响摄像头是否能点亮。

class ArActivity : AppCompatActivity() { private lateinit var session: Session private lateinit var arSurfaceView: GLSurfaceView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_ar) arSurfaceView = findViewById(R.id.ar_surface_view) session = Session(this) val config = Config(session) config.planeFindingMode = Config.PlaneFindingMode.HORIZONTAL session.configure(config) // GLSurfaceView 的渲染器要在会话 resume 之前绑定 arSurfaceView.onResume() session.resume() } override fun onPause() { session.pause() arSurfaceView.onPause() super.onPause() } }

这里有两个细节值得展开。第一是session.resume()的时机:必须在渲染表面已经做好准备之后调用。GLSurfaceView 的onResume()会创建或恢复渲染线程,如果先恢复会话再恢复渲染表面,首帧到来时没有可绘制的表面,画面就会卡在黑屏,而且没有任何异常日志。第二是 Config 的planeFindingMode,这个参数决定系统去识别哪种平面。只检测水平面时,系统会把更多算力集中在找地面或桌面,适合放家具、摆装饰物的场景;需要竖直平面时再加 HORIZONTAL_AND_VERTICAL,代价是检测耗时更长,首次完成平面识别的速度会慢一截。

这组生命周期还有一个特点是顺序敏感性。Activity 走到onPause时必须先暂停会话,否则相机资源被占住,下一个 Activity 打开摄像头就会失败。反过来,onResume时也要处理一种情况:应用退到后台再回来,会话状态可能已经重置,要对 Session 做一次重新配置或重启,而不是直接继续用旧会话。

3.2 点按放置锚点:把屏幕坐标换算成空间坐标

锚点是 AR 场景里「把虚拟物体固定在现实空间某个位置」的机制。用户的触摸坐标落在屏幕的二维平面上,需要把它映射成一条从相机出发的空间射线,再与检测到的平面求交点。

override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action != MotionEvent.ACTION_UP) { return super.onTouchEvent(event) } val frame = session.update() val hitResults = frame.hitTest(event.x, event.y) if (hitResults.isEmpty()) { Toast.makeText(this, "这里没有检测到平面,换个位置试试", Toast.LENGTH_SHORT).show() return true } val hit = hitResults[0] val anchor = try { hit.createAnchor() } catch (e: Exception) { return true } placeModel(anchor) return true }

hitTest返回一个按深度排序的结果列表,取第一个通常是最靠近用户的交点。这里必须处理两个高频问题:一是createAnchor()可能在会话状态异常时抛异常,裸奔的话会直接闪退,需要包一层 try-catch;二是用户快速连点时,每次点击都创建锚点会让场景堆满模型,正确的商业产品里要做 300 到 500 毫秒的点击节流。

还有一个更深层的边界:frame必须是当前相机帧的有效状态,如果session.update()还没跑过,或离上一帧太近,hitTest拿到的可能是过期帧,碰撞点会明显偏离手指位置。这个问题不容易察觉,表现在用户点按到一个物体旁边,模型却出现在远处或歪了。我习惯在onDrawFrame里保存最新帧引用,触摸事件直接读缓存帧的hitTest,而不是在触摸回调里再调一次session.update()。

3.3 模型加载与帧循环:让物体「钉」在现实里

模型加载是异步操作,本地一个 5MB 的 GLB 模型在低端机上可能要几百毫秒。从点击到模型出现的这段空窗期,最好给用户一个可感知的反馈。

private fun placeModel(anchor: Anchor) { val node = AnchorNode(anchor) ModelBuilder.builder() .setSource(ContextCompat.getDrawable(this, R.drawable.model_asset)) .build() .thenAccept { renderable -> node.renderable = renderable arSceneView.addChild(node) isLoading = false } .exceptionally { e -> Log.e("ARPlacement", "模型加载失败", e) null } }

thenAccept回调里的renderable一旦挂到节点上,节点就会跟随锚点的空间位置变化。AnchorNode 的位姿由 ARCore 每帧更新,所以模型看起来才像是被钉在现实世界里。

帧循环里的活通常很轻:更新会话、获取相机纹理、把模型摆放信息同步给渲染器。

override fun onDrawFrame() { val frame = session.update() val camera = frame.camera // 对每个已放置的锚点节点,把模型朝向修正为面向相机 for (node in placedNodes) { if (node.parent != null && node.renderable != null) { val direction = node.worldPosition - camera.position node.worldRotation = Quaternion.lookRotation(direction, Vector3.up()) } } }

这段代码解决的是一个真实问题:模型默认的方向常常和用户期待的不一致。比如摆一张桌子,用户希望桌面法线朝上;放一个箭头指示牌,用户希望箭头面朝自己。做一次lookRotation把模型朝向相机,能省去美术反复调模型旋转轴的扯皮。真正需要单独优化的是当锚点丢失时模型会「跳走」的场景,处理方式比较多,后面避坑章节会细说。

4. AR-Android 的四个必调参数:前三个影响观感,第四个影响开发排期

4.1 平面检测模式:水平、垂直还是都要

平面检测是 AR 体验的地基。模式选错,用户要么等不到平面提示,要么识别出来的地面歪得离谱。ARCore 的模式有三档:只检测水平面、只检测竖直面、两者都检测。

从数据看实际差距:水平面检测在普通室内环境下通常两三秒内出结果,竖直面由于墙面纹理信息少,往往需要用户慢慢扫过墙面才能锁定。所以产品设计上,如果主交互是「把虚拟柜子放地上」,就只开 HORIZONTAL,并在界面上提示用户「对着地面慢慢移动手机」。如果开了竖直面检测,就要给用户更长的等待预期,不能拿水平面的速度标准来衡量。

val config = Config(session) config.planeFindingMode = Config.PlaneFindingMode.HORIZONTAL_AND_VERTICAL config.updateMode = Config.UpdateMode.LATEST_CAMERA_IMAGE session.configure(config)

UpdateMode.LATEST_CAMERA_IMAGE是一个常被忽略的选项。默认模式下,会话更新可能会等待相机图像就绪,导致画面流畅度受影响;设置为 LATEST_CAMERA_IMAGE 后,系统会用最新图像更新跟踪状态,视觉上拖影更少。代价是传感器数据融合的延迟略高,但对大多数展示类 AR 场景来说,视觉优先是对的。

4.2 光照估计:虚拟物体能不能融入现实,靠它

不调光照参数,模型就像悬浮在场景上——亮度对不上,阴影方向也对不上。ARCore 的光照估计分两种模式:一种是简单的环境光强度估计,只给一个整体亮度值;另一种是环境 HDR,会捕捉场景的球面光照信息,让模型表面的明暗变化和现实环境一致。

val config = Config(session) config.lightEstimationMode = Config.LightEstimationMode.ENVIRONMENTAL_HDR session.configure(config) // 渲染时把估计的光照应用到模型 val lightEstimate = frame.lightEstimate modelNode.renderable?.setShadowCaster(true)

有一点需要注意:光照估计模式必须和渲染器配合。封装库如果本身不支持 PBR 光照,开了 HDR 也不会有明显效果,反而增加计算开销。打开光照估计后要做的验证是,把手机从窗边移到室内暗处,观察模型亮度变化是否平滑——如果出现亮度跳动,多半是 HDR 参数没跟上实时估计,需要手动把变化频率做一下平滑。

4.3 渲染分辨率与功耗:清晰度和发热怎么取平衡

一台中端手机跑 1080p 渲染和 720p 渲染,发热差异肉眼可见。项目早期可以用一个全局配置来控制渲染目标分辨率,而不是写死。

val renderConfig = RenderConfig.builder() .setResolution(1920, 1080) .setCameraStreamMode(CameraStreamMode.FULL_FRAME) .build()

真机上测过的经验是这样的:720p 渲染加全帧相机流,视觉清晰度已经能满足大部分商品展示场景;1080p 只是放大看纹理时更锐利一些,但持续运行十分钟后,手机背部温度能差 3 到 5 度。这个差异在夏天会被用户直接感知。产品验收时,如果 AR 是核心功能,建议默认 1080p;如果 AR 只是辅助手段,720p 更稳妥。以后遇到低端机型,再按运行内存动态降级。

4.4 遮挡:虚拟物体为什么应该被墙挡住

AR 场景里一个违背直觉的现象是:虚拟物体永远画在真实物体上层。桌球放到桌面边缘,本该被桌子边缘挡住的部分,会直接叠在桌子上。这就是遮挡处理。

val depthMode = if (session.isDepthModeSupported(Config.DepthMode.AUTOMATIC)) { Config.DepthMode.AUTOMATIC } else { Config.DepthMode.DISABLED } config.depthMode = depthMode

开启 Depth 模式后,系统会生成场景深度图,渲染器可以据此判断虚拟物体哪些像素应该被真实物体覆盖。这个参数的坑在兼容性:支持深度模式的设备占比有上限,低端机基本不支持。所以代码里必须先isDepthModeSupported判断,不支持就降级为无遮挡版本。实际上卖相最好的 AR 商品展示,几乎都开了深度遮挡;没开之前,用户很容易用一个手势在虚拟物体前比划一下,然后立刻指出「它是假的,手穿过去了」。

5. AR-Android 避坑记录:四个高频翻车现场与排查顺序

5.1 现象:摄像头黑屏,没有任何报错

黑屏是最磨人的问题,因为它不崩溃、日志也少。常见原因有三类:权限弹窗还没点完就开始会话、摄像头被其他应用占用、会话恢复顺序错误。排查顺序先看 Logcat 有没有 Camera 相关的异常,再确认session.resume()是否在渲染表面 ready 之后执行。解决方法也直接:权限回调成功后再初始化会话;onPause 时释放会话,onResume 时重建。

5.2 现象:模型原地抖动,或者随手机转动漂移

这个现象在所有 AR 应用里都可能出现。原因分两层:一是光线弱,相机帧质量差导致姿态解算抖动;二是当前设备的陀螺仪没有校准,系统融合出来的位姿噪声大。解决时先判断是全局抖动还是局部抖动。全局抖动且出现时间固定,去检查光线和纹理;只有某个锚点位置的模型抖,去检查这个锚点创建时是否碰到移动中的物体上。锚点必须创建在静态平面上,人走过去挡住平面后再创建锚点,大概率飘。

注意:地面是反光瓷砖时平面检测会反复失败,因为反光面的特征点在每一帧变化很大。遇到这种场景,引导用户换一个粗糙纹理的地面。

5.3 现象:模型被真实物体穿透,或者被地面吃掉一半

穿透多半是深度遮挡没开,前面第 4.4 节已经覆盖。这里补充一个更隐蔽的原因:模型本身的碰撞体和包围盒没设置。某些渲染库加载 GLB 模型后默认没有碰撞体,系统不知道模型的真实空间范围,遮挡计算只能用锚点近似。解决是在模型加载后设置碰撞体尺寸,通常用归一化包围盒自动生成。模型被地面吃掉的常见原因是模型锚定在了平面上、但模型基准点的原点和锚点重合,美术建模时原点不在底部,模型就会陷进地面。检查一下模型原点,必要时在节点上加一段垂直偏移。

5.4 现象:应用切后台再回来,AR 画面恢复但模型全没了

这个问题出在 Activity 重建时没有保存锚点状态。ARCore 的锚点绑定的是会话生命周期,会话销毁后锚点随之失效。切后台导致 Activity 重建、会话被销毁,再回到前台时旧锚点已经全部不可用。解决思路是:在onSaveInstanceState里记录每个锚点的空间 pose 和语义信息,Activity 重建后按 pose 重新创建锚点。对于要长期保存的锚点,把 pose 数据同步到云端,下次启动直接恢复——这也是 AR 应用完成「记住了摆放位置」的基建做法。需要说明的是,锚点恢复本身有位置误差,实际偏移通常在厘米级,产品设计上要给用户一个微调手势。

6. 交付前要做的三项验证:把 AR 场景从「能跑」带到「敢上线」

进入提测阶段前,我会固定走三个验证流程,每个都能撞出隐藏问题。

第一项是光线验证。分别在明亮窗边、普通室内灯、傍晚昏暗走廊三个环境各跑十分钟,记录平面检测成功率和模型抖动次数。弱光下如果平面检测失败率高,要在 UI 上提示「增加光线」,而不是让用户自己干等。这个提示字段在需求文档里经常被忘记,但在用户反馈里排名前三。

第二项是动线验证。握着手机走一条包含转弯和上下楼梯的路径,连续放置多个锚点并来回绕行。AR 系统对位置的估计误差会随运动路径累积,绕一圈回到起点时,模型相对初始位置通常会有偏差。偏差数值在 5 厘米内可以接受,超过就要检查是不是初始化时手机还在移动。验证的最终目的是找到一个「建议用户开始的姿势」,一般要求用户先保持静止两秒再开始。

第三项是长时间会话内存观察。开启内存监控,持续运行三十分钟,观察原生内存和 Java 堆内存曲线。AR 应用最常见的隐性 bug 是每帧创建新对象,导致 GC 频率不断升高,表现就是运行越久画面越卡。抓取堆转储后,用内存分析工具看大对象持有链,重点排查模型纹理是否重复加载和帧对象是否被外部引用住。

最后说一个形成习惯的做法:每次调整配置参数,都记录当时的机型、系统版本、光线条件和观察结果。AR 的参数改动没有全局最优解,只有「在特定环境、特定机型下最适合」的解。这些记录攒到一定量后,你就能回答那个项目评审会上必被问到的问题:「这个功能在所有用户手机上效果有保障吗」——虽然还是不能保证,但至少你能说出哪些机型验证过、哪些参数怎么调出来的。希望这篇笔记能帮你少踩一些已经有人踩过的坑。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进