1. 从一次自动化测试需求说起为什么要动 screencap 的源码做过 Android 自动化测试或者远程协助类工具的朋友大概率都遇到过这样一个场景应用在后台运行或者屏幕处于锁屏、息屏状态但业务上就是需要拿到当前屏幕的画面。这时候你打开终端敲一句adb shell screencap -p /sdcard/shot.png十有八九会得到一个全黑或者纯色的图片甚至直接报错退出。这个现象背后的原因并不复杂。Android 原生的screencap命令走的是 SurfaceFlinger 的截图通道它默认只抓取当前处于前台、可见且未被安全策略拦截的图层。一旦应用退到后台或者系统判定当前界面涉及敏感内容比如支付页面、密码输入框截图结果就会被系统保护掉。对于做设备管理、远程运维、自动化测试框架的开发者来说这个限制直接卡住了核心功能。我最初接触这个需求是在给一个自动化测试平台做设备端采集模块的时候。测试用例需要在应用切到后台之后验证某些 UI 状态是否被正确保存而验证手段就是对比前后两张屏幕截图。原生screencap完全满足不了于是只能从 AOSP 源码里把screencap.cpp拎出来改造成一个可以在后台稳定抓屏的版本。这篇文章就把我改造screencap.cpp的完整思路、关键代码改动、编译集成方式以及实测中踩过的坑一次性讲清楚。适合有一定 Android 系统开发基础、需要做后台截屏或者系统级画面采集的读者。如果你只是想在普通应用里截个图那用MediaProjectionAPI 就够了不需要往下看但如果你面对的是系统级、后台、无界面交互的场景那这篇内容应该能帮你省下不少试错时间。2. 原生 screencap 的工作链路与后台失效的根因2.1 screencap 到底调用了什么要改造一个东西先得搞清楚它原本是怎么跑的。screencap.cpp位于 AOSP 的frameworks/base/cmds/screencap/目录下它的核心逻辑其实非常薄主要就是三件事解析命令行参数、调用ScreenshotClient或者直接和 SurfaceFlinger 通信、把拿到的ScreenCapture数据编码成 PNG 写到文件或标准输出。在较新的 Android 版本里screencap底层依赖的是ISurfaceComposer::captureDisplay这个 binder 接口。SurfaceFlinger 在收到请求后会遍历当前所有可见的图层按照 Z 序合成一张位图返回。注意这里的关键词是可见的图层——这就是后台截图失效的第一层原因。2.2 后台失效的三层原因我把实测中遇到的后台截图失败原因归纳为三类理解这三层后面的改造方向就清晰了。第一层是图层可见性过滤。SurfaceFlinger 在合成截图时会跳过那些被标记为不可见或者被其他窗口完全遮挡的图层。应用退到后台后它的主窗口通常会被设为不可见自然就抓不到内容。第二层是安全图层拦截。从 Android 5.0 开始系统引入了FLAG_SECURE这个窗口标志。一旦某个窗口设置了它SurfaceFlinger 在截图时会直接把对应区域涂黑。这是系统级的安全机制防止恶意应用窃取敏感信息。第三层是权限与 SELinux 限制。screencap命令本身需要READ_FRAME_BUFFER权限在普通应用进程里是拿不到的。即使你通过Runtime.exec去调它也会因为 SELinux 策略被拒绝。只有系统签名或者 root 权限的进程才能正常执行。注意第三层限制是硬性的如果你的应用没有系统权限改screencap.cpp也解决不了问题。改造的前提是你的程序运行在系统进程或者有 root 的环境里。2.3 改造的核心思路既然原生逻辑卡在只抓可见图层上那改造方向就很明确了绕过可见性判断直接向 SurfaceFlinger 请求完整的显示缓冲区内容。具体来说就是把captureDisplay的调用参数做调整或者在更底层直接读取 framebuffer 设备节点。我最终采用的是修改captureDisplay调用参数 增加后台重试机制的组合方案。前者解决抓不到的问题后者解决抓不稳的问题。下面章节会分别展开。3. 改造 screencap.cpp 的关键代码改动3.1 定位需要修改的函数入口打开screencap.cpp你会看到主函数main里根据参数分支调用不同的截图逻辑。核心的截图函数通常叫captureDisplay或者screenshot它内部会构造一个DisplayCaptureArgs结构体然后传给SurfaceComposerClient。我改造时重点关注的是DisplayCaptureArgs里的几个字段displayToken、width、height、useIdentityTransform以及最关键的captureArgs中的layerStack和uid。原生代码通常把uid设成调用者的 uid这会导致 SurfaceFlinger 按照该 uid 的可见性策略来过滤图层。3.2 调整 captureDisplay 的调用参数我的做法是把uid强制设为 0也就是 root 的 uid同时把layerStack设为HWC_DISPLAY_PRIMARY对应的值。这样 SurfaceFlinger 在合成时就不会按照普通应用的可见性规则来过滤而是以系统级视角抓取整个主显示器的内容。代码改动大致如下基于 Android 10 的 AOSP 源码结构// 改造前 DisplayCaptureArgs captureArgs; captureArgs.displayToken displayToken; captureArgs.width width; captureArgs.height height; captureArgs.useIdentityTransform true; // 改造后 DisplayCaptureArgs captureArgs; captureArgs.displayToken displayToken; captureArgs.width width; captureArgs.height height; captureArgs.useIdentityTransform true; captureArgs.uid 0; // 关键以系统 uid 抓取绕过可见性过滤 captureArgs.layerStack 0; // 主显示层栈这里要说明一下uid 0的作用。SurfaceFlinger 在captureDisplay内部会调用getLayerStack和权限校验逻辑当 uid 为 0 时它会认为调用者是系统进程从而跳过针对普通应用的图层可见性检查。这是整个改造中最关键的一行。3.3 增加后台抓取的重试与超时机制光改参数还不够。实测发现当屏幕处于息屏状态时SurfaceFlinger 可能返回一个空的 capture 结果或者直接超时。这时候需要加一层重试逻辑。我的做法是在调用captureDisplay之后检查返回的ScreenCapture对象是否有效如果无效就等待 100 毫秒后重试最多重试 5 次。同时给整个截图操作加一个 2 秒的超时保护避免在异常情况下卡死。spScreenCapture screenCapture; status_t err; int retryCount 0; const int maxRetries 5; do { err composer-captureDisplay(captureArgs, screenCapture); if (err NO_ERROR screenCapture ! nullptr screenCapture-getWidth() 0) { break; } usleep(100000); // 100ms retryCount; } while (retryCount maxRetries); if (err ! NO_ERROR || screenCapture nullptr) { fprintf(stderr, captureDisplay failed after %d retries\n, maxRetries); return 1; }这段重试逻辑看起来简单但在实际场景里非常有用。特别是设备刚息屏或者刚切换应用的那一瞬间SurfaceFlinger 的合成状态还没稳定第一次抓取经常拿到空数据重试一两次就能成功。3.4 输出格式的兼容处理原生screencap支持-p参数输出 PNG 格式这个逻辑在改造后要保留。但后台抓取时有时候拿到的ScreenCapture数据格式和前台不一致需要做一层兼容。我的处理方式是统一走ScreenCapture::asBitmap()拿到SkBitmap然后再用SkImageEncoder编码成 PNG。这样无论底层返回什么格式输出都是标准的 PNG 文件。4. 编译、集成与权限配置的完整流程4.1 在 AOSP 源码树中编译改造完代码之后编译方式取决于你的环境。如果你有完整的 AOSP 源码树直接在frameworks/base/cmds/screencap/目录下执行mm或者mmm命令即可。编译产物是一个名为screencap的可执行文件位于out/target/product/device/system/bin/目录下。如果你没有完整源码树只有部分源码那就需要手动写一个Android.mk或者Android.bp。我用的是Android.bp内容大致如下cc_binary { name: screencap_custom, srcs: [screencap.cpp], shared_libs: [ libcutils, libutils, libbinder, libui, libgui, libsurfaceflinger_client, libskia, ], cflags: [ -Wall, -Werror, ], }这里要注意libsurfaceflinger_client这个库不同 Android 版本名字可能不一样Android 10 之后有些接口被合并到了libgui里。编译报错找不到符号时优先检查这个依赖。4.2 推送到设备并设置权限编译出来的screencap_custom需要推到设备的/system/bin/目录下。由于/system分区通常是只读的你需要先adb remount或者用mount -o rw,remount /system重新挂载。推送之后权限设置是重中之重。可执行文件需要755权限同时 SELinux 上下文要正确。我踩过的坑是文件推上去了权限也设了但一执行就报Permission denied查dmesg才发现是 SELinux 拦截。adb push screencap_custom /system/bin/ adb shell chmod 755 /system/bin/screencap_custom adb shell chcon u:object_r:system_file:s0 /system/bin/screencap_customchcon这行是关键把文件的 SELinux 上下文设成system_file这样系统进程才能正常执行它。如果你的设备 SELinux 是 enforcing 模式不做这一步基本必挂。4.3 在系统服务中调用如果你的截图逻辑是集成在系统服务里的那更推荐直接用 binder 调用而不是Runtime.exec去执行可执行文件。系统服务本身就有READ_FRAME_BUFFER权限直接调SurfaceComposerClient的接口效率更高也避免了 SELinux 的麻烦。我在一个系统服务里集成时用的是ScreenshotClient的封装类把上面改造过的captureDisplay逻辑包成一个 AIDL 接口供上层应用调用。这样上层应用不需要任何特殊权限只要绑定这个系统服务就能拿到后台截图。提示系统服务方案的前提是你的应用和系统服务在同一个进程或者有 binder 通道。普通第三方应用无法直接绑定系统级服务这条路只适合系统应用或者有系统签名的场景。5. 实测中遇到的五个典型问题与排查过程5.1 截图全黑先查图层可见性再查安全标志改造完成后第一次实测拿到的图片是全黑的。我当时的排查顺序是先确认captureDisplay返回值是否正常再检查ScreenCapture的宽高是否为零最后才怀疑到图层可见性上。排查下来发现虽然我把uid设成了 0但目标应用的窗口设置了FLAG_SECURE。这种情况下即使 uid 是 0SurfaceFlinger 依然会把对应区域涂黑。这是系统级安全机制无法通过改screencap.cpp绕过。解决办法有两个一是让目标应用去掉FLAG_SECURE如果是自己开发的应用二是在系统层面修改 SurfaceFlinger 的合成逻辑但这涉及系统安全策略不建议轻易动。5.2 息屏状态下抓取超时第二个问题是设备息屏后captureDisplay调用会卡住大约 5 秒后才返回超时错误。这是因为息屏时显示管线进入了低功耗状态SurfaceFlinger 的合成线程可能被挂起。我的处理方式是在截图前先通过PowerManager的wakeUp接口短暂唤醒屏幕或者直接给显示管线发一个setPowerMode请求把它从低功耗模式拉回来。当然这需要DEVICE_POWER权限。如果权限不够那就只能接受息屏时截图失败在业务层做降级处理。5.3 多显示器场景下的抓取错乱在支持外接显示器的设备上我发现抓到的画面有时候是副屏的内容而不是主屏。原因是captureDisplay的displayToken参数没有指定明确。原生代码里这个 token 是从SurfaceComposerClient::getInternalDisplayToken()拿的但在多显示器环境下这个接口返回的可能是最后活跃的那个显示器。修复方式是在调用前显式指定主显示器的 token通过getPhysicalDisplayToken(PhysicalDisplayId)来获取。这个细节在单屏设备上完全不会暴露但一到多屏场景就会出问题。5.4 高频截图导致 SurfaceFlinger 压力过大我的业务场景需要每秒抓 3 到 5 帧连续跑几分钟后设备开始明显发热SurfaceFlinger 的 CPU 占用飙升到 40% 以上。原因是每次captureDisplay都会触发一次完整的图层合成这个开销在后台持续跑是扛不住的。优化手段是降低抓取频率同时在两次抓取之间加一个短暂的休眠。另外如果业务允许可以把截图分辨率降下来比如只抓取屏幕的 1/4 区域这样合成开销会小很多。我在代码里加了一个downscale参数把宽高各缩小一半CPU 占用直接降到了 15% 左右。5.5 不同 Android 版本的接口差异从 Android 9 到 Android 13captureDisplay的接口签名改了好几次。Android 10 引入了DisplayCaptureArgs结构体Android 12 又把captureDisplay拆成了captureDisplay和captureLayers两个接口。这意味着同一份改造代码在不同版本上编译会报错。我的应对策略是用条件编译通过__ANDROID_API__宏来区分不同版本的代码路径。虽然麻烦但这是跨版本兼容最稳妥的方式。如果只针对单一版本那就简单很多直接按那个版本的接口写就行。问题现象根因解决方向截图全黑FLAG_SECURE 或图层不可见检查安全标志确认 uid 设置息屏超时显示管线低功耗唤醒屏幕或降级处理多屏错乱displayToken 不明确显式指定主屏 token高频发热合成开销大降频、降分辨率编译报错接口版本差异条件编译适配6. 后台截图的替代方案与选型建议6.1 MediaProjection普通应用的首选如果你的应用是普通第三方应用没有系统权限那MediaProjectionAPI 是唯一合规的路径。它通过用户授权的方式拿到屏幕采集权限可以持续获取屏幕画面。缺点是必须弹出授权对话框而且部分定制系统对后台采集有限制。MediaProjection的优势在于不需要改系统源码不需要 root兼容性好。缺点是首次使用需要用户手动授权而且授权状态在设备重启后会失效需要重新授权。对于面向普通用户的应用这是可以接受的但对于无人值守的自动化场景就不太合适了。6.2 系统服务方案系统应用的利器如果你有系统签名那直接在系统服务里集成改造后的截图逻辑是最优解。没有授权弹窗没有 SELinux 拦截性能也最好。我最终的生产方案就是走的这条路把改造后的captureDisplay封装成一个 AIDL 服务上层应用通过 binder 调用。这个方案的代价是你需要维护一个系统服务并且要处理服务生命周期、binder 死亡通知、并发调用等问题。代码量比单纯改screencap.cpp大不少但稳定性和性能是最好的。6.3 选型对比表方案权限要求后台能力性能适用场景原生 screencaproot/系统弱中调试、临时抓取改造 screencaproot/系统强中系统工具、脚本MediaProjection用户授权中中普通应用录屏系统服务集成系统签名强高系统应用、自动化平台选型时先问自己三个问题你的应用有没有系统权限需不需要在息屏或后台状态下抓取对性能要求高不高三个问题的答案基本就能定位到合适的方案。7. 几个容易被忽略的细节与个人经验改造screencap.cpp这件事代码本身不算复杂真正花时间的是环境配置和问题排查。我把自己踩过的几个细节单独拎出来说说。第一个是编译时的头文件路径。AOSP 里screencap.cpp依赖的头文件散落在frameworks/native/include、system/core/include等多个目录手动写Android.bp时很容易漏掉某个include_dirs导致编译报找不到头文件。我的建议是直接从 AOSP 原有的Android.bp里复制依赖列表再按需删减比从零写要靠谱得多。第二个是SELinux 策略的调试。遇到avc: denied的时候不要急着去改策略文件先用adb shell dmesg | grep avc看清楚是哪条规则被拒绝了。很多时候只是文件上下文不对chcon一下就能解决不需要动te文件。如果确实需要加策略也要遵循最小权限原则只开放必要的访问。第三个是截图数据的生命周期。ScreenCapture对象持有的是 SurfaceFlinger 的共享内存引用如果你在拿到数据后没有及时释放会导致 SurfaceFlinger 的内存持续增长。我在代码里用sp智能指针管理确保每次截图完成后引用计数归零。这个细节在低频调用时看不出来高频跑几个小时就会暴露。第四个是多线程并发调用。如果你的截图服务会被多个线程同时调用一定要加锁。SurfaceFlinger 的captureDisplay接口本身不是线程安全的并发调用会导致返回数据错乱甚至崩溃。我用的是一个简单的互斥锁把整个截图流程串行化虽然牺牲了一点并发性能但稳定性提升明显。最后分享一个实用技巧在调试阶段可以把每次截图的耗时和返回状态打到 logcat 里用adb logcat -s ScreencapCustom过滤查看。这样一旦出现问题能快速定位是超时、返回空数据还是权限被拒。这个日志我在生产环境也保留了只是把级别降到了 debug方便出问题时排查。这套改造方案我在几个不同 Android 版本的设备上都跑过核心逻辑是通用的主要差异在接口签名和 SELinux 策略上。如果你也在做类似的后台采集需求希望这些经验能帮你少走点弯路。
阅读完成 · 觉得有帮助?