Android 图形显示系统 · 第 五 篇

Vulkan on Android:低开销渲染入口

2026-09-2914,247 字30 张配图

本文由@溯光 出品,转载请注明出处 站点链接:技术博客页 | 公众号:溯光的图形笔记

Vulkan 简化渲染管线
题图Vulkan 简化渲染管线:命令缓冲提交 → 渲染管线 → 着色 → 输出到帧缓冲。本篇只借它的形状立起「Vulkan 长什么样」,管线内部各阶段属《实时渲染管线探析》,正文不展开@ Wikimedia Commons · Alexander Overvoorde(CC BY-SA 4.0)

核心问题:上一篇结尾欠的是"缓冲传往哪、谁来决定",这一篇要问的更靠前一件事是:在 Android 上,一个应用到底走哪条"入口"来接上 GPU?

一个具体的问题就能把它逼出来:本机的默认渲染后端是 OpenGL 还是 Vulkan? 抓帧工具的说明文档暗示"默认走 Vulkan",可我在这台设备上两次读到 Pipeline=Skia (OpenGL)。是谁在说谎?都不是。答案藏在一个没人摆到台面上的矛盾里:"通用默认"和"本机默认"是两件事,而本机的默认不是我改的,是模拟器在内核命令行上钉死的。

承接上一篇。ep04 讲的是缓冲队列怎么记账,ep03 讲的是缓冲怎么被申请、被画、被交出去,ep02 讲的是节拍怎么打在帧上。这三篇都默认了一件事:命令最终要交给 GPU,而总得有个"入口"把它接进去。 这个入口,在 Android 上主要就是 Vulkan,而它恰恰是前三篇各欠下的一笔账。

这件事不是我临时起意补的。前几篇正文里共有两处把"Vulkan 后端路径"指向本篇:

出处 原话
ep03 §0.3 把 Vulkan 后端路径列为 ep05 的正题
ep04 §0.3 把「Vulkan 后端路径」与 BufferQueue 的桥接列为 ep05

所以这一篇是来还债的:把"应用怎么接上 Vulkan、系统怎么把它加载起来、渲染后端怎么被选中"一次讲清。它回答的不是"Vulkan 怎么用"(那是《实时渲染管线探析》),也不是"GPU 怎么算"(那是《GPU 架构剖析》),而是入口本身:谁加载、谁被选中、谁在跑。

三篇共用同一条链路,各篇量的东西各不相同:

维度 ep03《RenderThread》 ep04《BufferQueue》 ep05(本篇)
关心的对象 交接:活与缓冲怎么交出去 记账:队列自己怎么记这一笔 入口:命令从哪条门进 GPU
量什么 时间与占比 数量与状态 加载路径与后端身份:加载链、Pipeline=、版本号
主要命令 framestats + 双线程轨 SurfaceFlinger 的队列段 getprop + dumpsys gpu + cmd gpu vkjson + logcat
典型结论 dequeueBuffer 的双峰 公式值 2、实打 slot 2、已分配 3 本机默认 GL,且切到 Vulkan 会启动即崩(设备建成但走不通),同一台机器六个版本号

本篇也不涉及:应用/引擎能画多少与功耗带宽取舍(《移动端图形与功耗》已写)、渲染算法与画面技术(《实时渲染管线探析》《实时光线追踪与混合渲染》已写)、具体渲染器内部的光栅化实现(《2D 矢量引擎深挖》已写)、GPU 硬件内部(《GPU 架构剖析》已写)、Perfetto 与 RenderDoc 自身的原理(《图形性能剖析工具链》已写)、队列与栅栏(ep03/ep04 已写)、合成决策与送屏(ep06 展开)、HWC 叠加层与合成模式(ep08 展开)。为什么不涉及:这一篇的价值集中在"入口"这一个点,把加载链、后端选择、版本门槛三样讲透,掺进别的题域,这件事就讲不透了。完整边界见 §0.3。

系列文章写作风格说明(沿用前四篇,不再重复解释)

  • 术语双语:首次出现的核心术语写成「中文(English)」,后文可只用英文;
  • 三层分开说:官方文档怎么写、AOSP 源码怎么做、本机跑出来是什么,这三层分开陈述,不混成一句;
  • 读数标口径:每一个数字都标来源与条件(机型、Android 版本、命令、采样时刻);
  • 模拟器与真机分开写:本机走 gfxstream + SwiftShader 软实现,凡是性能类结论一律不外推为真机;HWC overlay 与 ARR 这类拿不到的东西如实标"需真机"。

系列主要内容总览

  1. 【Android 图形显示系统】(一) 总览:从 App 到屏幕的图形管线
  2. 【Android 图形显示系统】(二) Choreographer:谁在召唤 Vsync
  3. 【Android 图形显示系统】(三) RenderThread 与帧提交:从显示列表到一块缓冲
  4. 【Android 图形显示系统】(四) BufferQueue:图形缓冲区的邮局
  5. 【Android 图形显示系统】(五) Vulkan on Android:低开销渲染入口(本篇)
  6. 【Android 图形显示系统】(六) SurfaceFlinger:在 Vsync 之后做什么
  7. 【Android 图形显示系统】(七) ASurfaceControl:Android 10 的透明事务
  8. 【Android 图形显示系统】(八) HWC:GPU 合成之外的硬件叠加
  9. 【Android 图形显示系统】(九) HWC2:Vsync 回调和虚拟显示路径
  10. 【Android 图形显示系统】(十) Vsync 偏移:应用、SF、屏幕的三步舞
  11. 【Android 图形显示系统】(十一) 三缓冲、多刷新率与 ARR
  12. 【Android 图形显示系统】(十二) 虚拟显示与屏幕录制
  13. 【Android 图形显示系统】(十三) 剖帧实战:Perfetto FrameTimeline 抓 jank

相关资源链接


这一篇回答什么,不回答什么

OK,先把坐标系挪正。前四篇给了地图、给了一条叫醒链、给了一次交接、给了一本账;这一篇要给的是一个门,也就是命令进入 GPU 的那条入口,以及这条入口在 Android 上是谁加载的、谁被选中、谁在跑。

0.0 内容总览:本篇知识地图

好了,先把十章各自回答的问题钉在一张表上。它同时也是本篇的目录总览。如果时间紧,第二章(加载链)与第五章(后端怎么被选中)是这篇最不可替代的两章,前者把"命令怎么进来"讲清,后者把它变成这台设备上可以复现的实验。

章 回答的问题 关键结论
零 这一篇补哪一段 一个入口的三层坐标;前三篇欠的账
一 为什么 Android 要另开一个入口 它取代 GLES;ANGLE 跑在它之上而不是之下
二 加载链是几步 loader → ICD → HAL;层怎么被搜到
三 低开销到底省的是谁 省的是 CPU 侧的组织开销,不是 GPU 干活的量
四 版本与门槛 六台阶时间线;apiVersion 与 Android 版本两项都要对
五 后端怎么被选中 RenderPipelineType 两条 pipeline;切换实验可复现
六 验证层与调试开关 只讲开关,不讲内部
七 实测:本机这台设备的入口 驱动上报、加载器、后端、六版本号、模拟器三处限制
八 五处流传很广的说法 逐条核对,不立空靶
九 四项可执行动作 每条都给命令与判据

0.1 一个入口,三层坐标

好,先立框架。说"Vulkan 是 Android 的底层图形 API",这句话里其实藏着三个不同的角色,懒懒地混着一说就乱。把它们拆成三个问题,就是本篇的一条主线:

第一层,谁加载它。 应用链接的 libvulkan.so 是一个加载器(loader),它负责找到真正的驱动。这一层对应本篇第二章。

第二层,谁被选中。 加载器从系统里挑出一个驱动(ICD),也就是某个 vulkan.<厂商>.so。这一层是入口的"实体",对应第二章的加载结果。

第三层,谁在跑。 即便驱动被加载了,具体到渲染管线,还有一层"后端选择":Android 的界面渲染(HWUI)到底用 OpenGL 后端还是 Vulkan 后端,是另有一个属性决定的。这一层对应本篇第五章。

三层各司其职,缺一不可,也正是本篇标题里"入口"二字的全部含义。

0.1.1 入口的三层坐标图

入口的三层坐标
图 5-1入口的三层坐标:谁加载、谁被选中、谁在跑,三层各司其职,本篇主线就翻这三个问题@ 本系列绘制

0.1.2 本篇与前几篇的量纲分工图

好了,还是那句老话:几篇共用同一条链路,但量的东西不一样。上面开头那张表划了边界,这里再补一个量纲上的区分图。前几篇量的多是"时间"与"数量",本篇量的主要是"身份"与"路径":本机默认后端是 OpenGL 还是 Vulkan、驱动自报是 1.4 还是 1.2、层从哪个目录被搜到、Pipeline= 在什么条件下有一行什么条件下连那行都不存在。这些不是性能数字,读的是"这条路走通了没有、走的是哪条岔路"。

四篇的量纲分工
图 5-2前三篇欠的账与本篇的量纲分工:ep02 量订阅状态、ep03 量时间占比、ep04 量数量状态、本篇量身份与路径,四篇不重不漏@ 本系列绘制

0.2 本篇不涉及什么(边界声明)

开篇把边界说死,是这几篇的统一约定。本篇:

  1. 不讲 Vulkan 怎么用:不着色器、不写 render pass、不排命令缓冲,这些给指针(§三尾部资源链接)。
  2. 不讲 GPU 怎么算:不展开管线各阶段、不讲光栅化实现(《实时渲染管线探析》《GPU 架构剖析》《2D 矢量引擎深挖》已写)。
  3. 不讲迁移的性能结论:本机是模拟器软实现,"Vulkan 起不来"是环境产物,不是 API 结论,不写成 Android 通例(§7.4 展开)。
  4. 不搬运厂商配图、不使用任何 logo 文件:Commons 上 Vulkan/Khronos/OpenGL 的 logo 全是注册商标,且对技术论证无贡献(§8.4)。
  5. 不假装读到读不到的东西:真机厂商 ICD 名、Vulkan APEX、验证层的"被加载"演示、以及合成侧的 Vulkan 路径,本机都拿不到,如实标注(§7.3)。

这五条边界不是客套,它们直接决定了本篇的证据类型。原理上,一篇技术文章的结论强度受限于它的取证手段:能用命令读出来的(属性、计数、日志)是一手证据;官方文档写明的规则是权威依据;社区文章与教程只能作旁证。本篇把这三类出处逐处标出来,凡属"模拟器环境产物"的结论一律不外推。作为对照,官方 Vulkan 总览页与 AOSP 的实现 Vulkan 文档是本篇回引最多的两处权威依据——凡是它们已经写明的事,本篇尽量引原话,而不是转述成自己的说法。

本机观测环境(本篇 11 张本机真图的来源,先交代清楚):无物理设备,统一用 Pixel_7 无窗冷启动(-no-window -no-audio -no-boot-anim -gpu swiftshader_indirect -no-snapshot);宿主 Android emulator 36.6.11.0,图形后端 gfxstream;系统镜像 android-36/google_apis_playstore/x86_64;设备侧 ro.build.version.release = 16、sdk = 36、user / release-keys、ro.debuggable = 0。

0.2.1 本机观测环境与四类读数来源面板

本机观测环境与四类读数的来源
图 5-3本机观测环境与四类读数的来源:Pixel_7 无窗冷启动、四类读数各自的取数命令,本篇 11 张本机真图全部来自这套环境@ 本机实测(Pixel_7 AVD,Android 16 / API 36)

一、为什么 Android 要另开一个入口

在拆加载链之前,先回答一个更"为什么"的问题:既然 Android 一直有 OpenGL ES,为什么还要一个 Vulkan? 这一个问题,牵扯出三个概念的位置关系:GLES、Vulkan、还有夹在中间的 ANGLE。

1.1 官方给的一句话定位

开发者文档里《使用 Vulkan 处理图形》的原话是这么说的:

Vulkan 是 Android 主要的底层图形 API,取代(supersedes)OpenGL ES。OpenGL ES 仍受支持,但不再为它开发新功能。

言归正传,这一句里最关键、也最容易被读偏的词是"取代"。它不指"OpenGL ES 立刻消失",而指"新功能不再往 GLES 上加,未来属于 Vulkan"。这就像系统里同时有新旧两条路:旧的还通车,但新工程默认走新的那条。

要理解这三个概念谁在上谁在下,一张层次图最清楚:

1.1.1 GLES / Vulkan / ANGLE 的层次关系

GLES / Vulkan / ANGLE 的层次关系
图 5-4GLES / Vulkan / ANGLE 的层次关系:ANGLE 不在 Vulkan 下面撑着它,而是跑在 Vulkan 之上,把 OpenGL ES 调用翻译成 Vulkan;真正的驱动仍由厂商提供@ 本系列绘制

这一层最容易读反的地方在这里:ANGLE 不在 Vulkan 下面撑着它,恰恰相反,ANGLE 跑在 Vulkan 之上,把 OpenGL ES 的调用翻译成 Vulkan。真正的驱动仍是 GPU 厂商(IHV,Independent Hardware Vendor)提供的。这一点在 §1.3 会再钉一次,因为中文侧最容易把 ANGLE 读成"驱动"。

好了,趁着"谁在上谁在下"还热乎,把 GLES 与 Vulkan 的机制差别摊成一张表,后面几章反复回看都方便。这张表只列机制层的六处差别,不含任何性能结论:

维度 OpenGL ES Vulkan
状态处理 全局状态机,改一处即影响全局 状态打包成对象(PSO),绘制时只切换对象
命令提交 单线程命令流,调用即录制 命令缓冲可多线程录制,再一次性提交队列
着色器 文本源码(GLSL / ESSL),运行期编译 二进制中间表示(SPIR-V),可离线编译
内存 驱动代管,应用少操心 应用显式选择内存类型与堆(memory type / heap)
同步 多为隐式,驱动替你插等待 显式:栅栏(fence)、信号量(semaphore)、管线屏障(barrier)
错误检查 驱动用启发式猜,行为依厂商 多数校验移出运行期,改由可选的验证层承担

这张表的读法是责任转移,不是"谁做得多谁做得少":右列不是"Vulkan 省掉了这些事",而是"Vulkan 把这些决定权交还应用,同时把责任一起交了过去"。上表里被省下的每一个隐式动作,都会在应用侧变成一个必须显式安排的动作——这也是为什么第三章要说清"低开销"是一把双刃剑:它省的是驱动替你猜的成本,不是省掉工作本身。

顺带把 ANGLE 的开启方式说全,因为它常被误当成"默认就在跑"。官方口径是它按应用启用,靠一组 angle_gl_driver_selection_pkgs 与 angle_gl_driver_selection_values 这类开关指名哪些包走 ANGLE、走哪条后端。对本篇的主角没有影响:一个直接用 Vulkan 的应用根本不经过 ANGLE,ANGLE 存在与否都不改变它的加载链。把它读成"Android 图形栈的地基"是错的,它只是 GLES 这条旧路旁边的一条可选的翻译车道。

1.2 Vulkan 长什么形状

"底层图形 API"到底长什么样?为了立起这个形状,我用一张 Wikimedia Commons 上的 Vulkan 简化管线图作题图(见文首题图,此处不再重复贴同一张)。为什么取它而不自绘?因为本系列纪律要求:能用外部真实素材就不用自绘示意,而这篇图带的许可(CC BY-SA 4.0)允许原样引用。边界必须说清:这张图展示的"render pass 里跑什么"属于《实时渲染管线探析》的题域,本篇只借它的形状告诉你"Vulkan 的应用侧动作大致是:往命令缓冲里录指令、提交队列、然后由管线逐阶段算出图像"。

回到那张题图,能读出的只有三件与"入口"有关的事:命令从应用侧录进命令缓冲(这是入口的输入端)、提交到队列(这是交接点)、由管线算出图像(这是出口)。至于管线内部每个阶段怎么算,本系列不在这篇展开,留一句指针即可。

1.3 官方定位的拆解:"取代"指什么、不指什么

回到官方那一句,它值得逐词拆一遍,因为拆完你会发现它能带出整个系列的地图。

"主要的底层图形 API":底层(low-level)是说它离硬件更近、暴露更多控制权。对显示链路而言,这意味着一张帧从命令到上屏,中间系统给你的"自动"更少,你要自己安排的部分更多。

"取代 OpenGL ES":取代的是新功能开发的方向,不是存量兼容。所以一门新引擎学图形 API 该学什么,答案很明确。

"不再为它开发新功能":这是给 GLES 判的"维护态",不是"死刑"。它解释了为什么我们会在本机上看到 GLES 还在跑、还跑得动。

关于 ANGLE 的定位,再钉三个钉子,直接引用官方页的最新口径:ANGLE 自 Android 15 起作为可选层(optional layer),在 Vulkan 之上运行 OpenGL ES,用来标准化 OpenGL 实现、提升兼容性;它不是驱动,真正的驱动仍由 GPU 厂商提供;它不是默认全量启用,要通过开关按应用启用(angle_gl_driver_selection_pkgs 一类)。

1.3.1 官方定位一句的拆解图

官方定位一句的拆解
图 5-5官方定位一句的拆解:「取代」指向「新功能不再加」,ANGLE 是跑在 Vulkan 之上的可选翻译层,而不是驱动@ 本系列绘制

好了,这一章把"为什么另开入口"和"三个概念谁在上谁在下"立住了。下一章,我们进入本篇最硬的一手来源,看这条入口到底是怎么被加载起来的。


二、加载链:loader → ICD → HAL

OK,言归正传。这一章回答"谁加载它、谁被选中"。AOSP 的《实现 Vulkan》是把这条链写得很具体的一手来源,它把加载链拆成三个环节:加载器(loader)、驱动(ICD)、以及封装驱动的 HAL 机制。

2.1 加载链三步:loader → ICD → HAL

先把三步画出来。

第一步,加载器(loader)。 源码在 platform/frameworks/native/vulkan,安装为 /system/lib[64]/libvulkan.so。它是应用链接的那个库,你的应用调 vkCreateInstance,先落在这里。Android 用的是自己的加载器,不是 LunarG 的,这是官方原生引擎支持页专门强调的一句,而且它提醒:构建期直接链接系统库会多一层 dispatch 开销。

第二步,驱动(ICD)。 加载器按规则去找真正的驱动:首选 /vendor/lib[64]/hw/vulkan.<ro.hardware.vulkan>.so,另有 vulkan.<ro.product.platform>.so 等备选。这句话有个直接后果:ICD 文件里有厂商名:真机上是 vulkan.adreno.so(高通)、vulkan.mali.so(Arm)一类的名字。

第三步,HAL 封装。 Android 7.0 起用 hw_module_t / hw_device_t 这套 HAL 机制去封装驱动,open() 时传入常量 HWVULKAN_DEVICE_0。驱动不裸奔,它被包在硬件抽象层里,系统通过 HAL 找到它、打开它。

2.1.1 加载链三步图

加载链三步
图 5-6加载链三步:loader → ICD → HAL,open() 传 HWVULKAN_DEVICE_0,驱动经硬件抽象层被打开@ 本系列绘制

老一点的中文资料里,《Android N 中的 Vulkan 支持》把这条链拆得最细:vkCreateInstance → EnsureInitialized → OpenHAL → hw_get_module("vulkan") → hwvulkan_module_t/hwvulkan_device_t。它的价值是"形状",局限是版本偏旧(基于 Android 7 / Vulkan 1.0,没有 Android 15 的 APEX)。本篇引用它的形状,不引用它的版本号。

下面把这条链落到能读的代码层级。这段伪代码只保留"谁调谁",略去错误分支与 dispatch 细节:

// 1) 应用侧:入口只有一个调用
vkCreateInstance(&createInfo, NULL, &instance);

// 2) loader 侧:第一次调用触发初始化(源码路径 frameworks/native/vulkan/libvulkan)
EnsureInitialized();                        // 决定加载哪个 ICD、挂哪些 layer
OpenHAL();                                  // 打开 HAL
hw_get_module("vulkan", &module);           // 走 hardware.h 的模块查找
module->open(HWVULKAN_DEVICE_0, &device);   // 拿到封装后的设备句柄

// 3) ICD 侧:真正实现 vk* 的是这个 .so(本机为 vulkan.ranchu.so)

这段代码里藏着三个要点。第一,"一个调用"是表象:应用只写了一次 vkCreateInstance,但 loader 在它背后做了"读属性拼出 ICD 名 → dlopen → 取符号 → 建 dispatch 表"这一整套动作,代价压在第一次调用上。第二,hw_get_module 这一步说明了驱动为什么要包一层 HAL:Android 不希望图形驱动完全裸奔,硬件抽象层负责找到它、打开它,打开时用常量指定设备序号。第三,loader 是 Android 自己的,不是 LunarG 的通用 loader。这一点的实际后果在构建期:如果构建配置里直接链接系统库,调用会多绕一层 dispatch 转发,这是官方专门提醒过的一条开销。

好了,加载链讲完,顺手把"层从哪儿被搜到"的来源也收全,因为这决定了第六章那张开关树能不能落地。全部来源只有三类,彼此不重叠:

来源 怎么生效 谁适用
系统 native 库目录 自动搜 /system/lib64 与 /system_ext/lib64 所有应用
开发目录 自动搜 /data/local/debug/vulkan 仅可调试应用
显式指定 VK_LAYER_PATH 环境变量 / debug.vulkan.layers 属性 按需,调试场景

三类的排序不是随意的:前两类是"系统替你搜",第三类是"你自己指路"。本机的 logcat 只打出前两类,第三类一次都没出现——原因在 §2.4 交代。

2.2 实测:本机这台设备的加载器与 ICD 文件

好了,前面是"文档怎么写"与"源码怎么做",现在落到"本机跑出来是什么"。在本机的文件系统里,这套加载链是实打实的一对文件:

-rw-r--r-- 1 root root   223616  …  /system/lib64/libvulkan.so          # loader,约 218 KB
-rw-r--r-- 1 root root  1373184  …  /vendor/lib64/hw/vulkan.ranchu.so    # ICD,约 1.3 MB

2.2.1 本机加载器与 ICD 文件的路径与体积

本机加载器与 ICD 文件
图 5-7本机加载器与 ICD 文件:/system/lib64/libvulkan.so(223,616 B)与 /vendor/lib64/hw/vulkan.ranchu.so(1,373,184 B),ranchu 正是模拟器代号@ 本机实测(Pixel_7 AVD,Android 16)

两个信息要留意。第一,vulkan.ranchu.so 里的 ranchu 是 Android 模拟器的代号,说明这台机器用的是模拟器专用 ICD(ro.hardware.vulkan = ranchu),不是真机的 Adreno/Mali 驱动。第二,这条链的形状(加载器 + ICD + HAL)是通用的,但具体文件名不能代表真机,正文只把通用画法给全,不用本机名去顶真机名。

口径警示 ①:本机 ICD 是 ranchu,真机厂商 ICD 的命名与路径在本机无法观察。凡用到 vulkan.ranchu.so 的地方,说的都是"这台模拟器",不是 Android 通例。

2.3 层(layer)怎么被搜索到

加载链还有一个常被忽略的分支:层(layer)。验证层是一类特殊的库,用于调试;加载器要按规则去"搜索"它们。AOSP 文档给出的规则是:不可调试应用只搜 native 库 libVKLayer_*.so;可调试应用另搜 /data/local/debug/vulkan。

为什么会有"可调试/不可调试"两种搜索范围?因为层是额外的库,加载它意味着信任它、也意味着可能的注入风险,所以系统对不可调试应用(普通发布 App)只搜系统的 public 路径,对可调试应用才放宽到开发目录。

2.3.1 层的发现路径分叉图

层的发现路径
图 5-8层的发现路径:不可调试应用只搜 native 库,可调试应用再多搜一个开发目录,两条分支互不重叠@ 本系列绘制

2.4 实测:logcat 里加载器正在找层的现场

这条"不可调试应用只搜 native 库"的规则,能不能在本机上看到活的现场?能。在本机 logcat 里,加载器启动时会直接打出它正在搜索的层目录:

D/vulkan: searching for layers in '/system/lib64'
D/vulkan: searching for layers in '/system_ext/lib64'

2.4.1 层搜索的 logcat 现场

logcat 里加载器正在找层的现场
图 5-9logcat 里加载器正在找层的现场:D/vulkan: searching for layers in '/system/lib64' 与 '/system_ext/lib64',25 秒窗口内合计 148 条、每条路径各 74 次,而 /data/local/debug/vulkan 一次都没出现@ 本机实测(Pixel_7 AVD,Android 16)

这一屏特别漂亮,因为它是官方文档的规则在设备上现形:文档说"不可调试应用只搜 native 库",本机日志直接打出它搜的正是 /system/lib64 与 /system_ext/lib64 这两条 native 路径,而没有 /data/local/debug/vulkan,因为它不是可调试应用(ro.debuggable = 0)。规则与现场对上了。

顺便交代一个本篇尾部的伏笔:宿主启动日志第 43 行写着 Disabling implicit Vulkan layers to avoid compatibility issues,等于说本机的隐式验证层被人为掐掉。这导致第六章只能讲"开关树与属性可读性",无法演示"层真的被加载"。这是本机的限制,如实登记(§7.3)。


三、低开销到底省的是谁

好了,"Vulkan 是低开销(low-overhead)的渲染 API"。这句话几乎人人都背得,但要问它到底省的是谁的开销,很多人会答成"省 GPU 的活"。这一章要把这个"谁"说清。

3.1 省的是 CPU 侧的组织开销

好了,先说结论:Vulkan 省的是 CPU 侧"组织绘制"的开销,不是 GPU"干渲染"的开销。

一张帧从 App 到屏幕上,CPU 要做一堆"组织"工作:把命令录下来、把状态设好、把资源绑上、把对象创建好。Vulkan 的思路是:把很多原本在每次 draw 时重复的钱,提前一次性付掉,然后绘制时只需切换对象。

3.1.1 省的是谁:CPU 组织开销 vs GPU 干活的量

低开销省的是谁
图 5-10低开销省的是谁:左边是 CPU 侧要付的组织开销(命令录制、状态设置、资源绑定),右边是 GPU 干渲染的量,Vulkan 省的是前者@ 本系列绘制

3.2 CPU 与 GPU 的分工:一张外部对照图

"CPU 管组织、GPU 管算"这个分工,用 Wikimedia Commons 上的一张CPU 与 GPU 分工图(CC0)做对照最直观。它把 CPU 的控制流与命令组织职责、GPU 的大规模并行计算职责画在左右两侧,中间的 API 层是两侧的调度入口。我们说"低开销",落点就是左侧这条 CPU 组织链上省事。它是通用对照图,不是 Android 专属,所以本篇只借它承担"分工"这一处的示意,不引申到具体驱动栈。

CPU 与 GPU 的分工
图 5-11CPU 与 GPU 的分工:CPU 负责控制流与命令组织,GPU 负责大规模并行计算,API 层就是两者之间的调度入口@ Wikimedia Commons · ScotXW(CC0)

需要提醒一句:这是通用对照图,不是 Android 专属。它讲的是"两类处理器的分工"这条普适结论,本篇只借它承担「省的是左侧那条链」这一处的示意,不引申到具体的驱动栈或 Android 加载链。

3.3 "低开销"的完整含义:三条合起来

那么"省"到底是省在哪几步?把几条官方与教程的表述合起来,可以拆成三条,而且三条都指向 CPU:

  1. 状态打包成 PSO,绘制时只切换对象,每次 draw 不再重建状态;
  2. 命令缓冲可以多线程录制:不再只有一条串行的命令队列,多线程各自录,最后提交;
  3. 驱动侧变薄:框架侧能自己安排的都自己安排了,驱动需要"猜、补、修"的动作少了。

再补一个从应用侧看的视角。Vulkan 设计指南列了应用侧"必须自己做"的事:避免软件模拟(VK_PHYSICAL_DEVICE_TYPE_CPU)、渲染期做旋转(preTransform)、减少 render pass 数量、选合适的内存类型、按更新频率分组 descriptor。"必须自己做"反过来读,就是"Vulkan 把这些负担从驱动搬到了应用",你获得了控制权,也接过了责任。

三条里最容易被一句"状态提前打包"糊过去的是第一条,值得把"打包"这个词拆开看。PSO(Pipeline State Object,管线状态对象)打包的不是某一个状态,而是一组在绘制前就固定下来的状态。按 Vulkan 规范的分段,一份图形管线对象大致覆盖下面这些段——逐段释义,这也是读任何 Vulkan 代码时最先要对上的清单:

  • 顶点输入(vertex input):顶点缓冲的绑定与属性格式,哪个缓冲出哪几个分量、步长多少;
  • 输入装配(input assembly):图元拓扑是三角形、三角形扇还是线段,是否带索引;
  • 视口与裁剪(viewport / scissor):画到画面哪一块(这一项常被声明为动态状态,录制时再改);
  • 光栅化(rasterization):填充模式、面剔除方向、深度偏置等;
  • 多重采样(multisample):采样数与采样遮罩;
  • 深度与模板(depth-stencil):是否开深度测试、比较函数、模板操作;
  • 颜色混合(color blend):每个颜色附件的写掩码与混合因子——这里正是取代 glBlendFunc 那一族调用的位置;
  • 动态状态(dynamic states):显式声明"这几项我要在录制时改",剩下的就被对象锁死。

于是"提前打包"的原理就清楚了:上列这些项只要在绘制中途被改一次,GLES 侧就得重设一遍并触发驱动的状态重算;Vulkan 侧则把它们做成一个不可变对象,绘制时切换的只是对象引用。切换引用比重新设置一整套状态便宜得多,省下来的正是驱动的比较、校验与推导。

第二条"多线程录制"有个容易踩的实现细节:VkCommandPool 不是线程安全的,所以正确做法是"每线程一个池",各自录各自的命令缓冲,最后统一提交到队列。要注意"多线程"省的不是 GPU 的时间,而是把 CPU 侧串行的录制工作摊到多个核上——如果录制量本来就很小,这条的收益近乎为零。

第三条"驱动侧变薄"听着最抽象,落到可指认的表现上其实就三件事:隐式同步变成显式栅栏(驱动不再替你在合适位置插等待)、状态跟踪变成对象校验(不再逐次比较全局状态的差异)、内存管理变成显式选择(应用按用途挑 memory type,驱动不再替所有资源猜最优位置)。这三件事合起来才是"驱动变薄"的完整含义——它薄掉的是替应用做决定的那些逻辑,不是薄掉功能。

3.3.1 PSO vs 全局状态机对照图

PSO vs 全局状态机
图 5-12PSO vs 全局状态机:左边 GLES 每次 draw call 都要重建状态、重绑资源;右边 Vulkan 把状态提前打包成 PSO,绘制时只需切换对象@ 本系列绘制

3.4 反鸡汤:Vulkan 不保证"自动更快"

写到这里必须踩一脚刹车,因为这一章最容易滑向宣传。《Vulkan 教程 18 · 阶段性总结》 的原话:

有些场景 OpenGL 与 Vulkan 性能没有任何差别……不需要多线程或非 CPU 密集时,用 OpenGL 即可。

这句是反鸡汤的好句。它说明"低开销"是指当你确实吃到了 CPU 组织开销这块瓶颈时,Vulkan 给了你把它砍薄的工具(多线程录制、PSO、薄驱动),但砍工具 ≠ 一定更快。如果你根本没到那个瓶颈,或没用好那些工具,两者可以没有差别,甚至 Vulkan 因为状态更复杂会更慢。

同理,《理解 Vulkan 的核心优势》自己也有一段"Vulkan 不是自动更快"的免责段,本篇把它正面引用。本篇全篇不引用任何"单核开销降低 XX%""同步开销降低 XX%"这类带精确百分比的无源数字,因为不可核对,一律改为定性表述。这是§8 那条"五处流传说法"的地基之一。


四、版本与门槛:六台阶时间线

OK,入口除了"怎么加载",还有一个"够不够用"的问题,也就是版本。这一章把版本谱系理清,并演示本机上同一时刻能读到多少个互相打架的号。

4.1 六个台阶:从 1.0 到 1.4 与 Android 版本的对应

把官方原生引擎支持页的映射与 AOSP 侧的要求整理成一条干净的时间线:

时间点 事件
Android 7(API 24) 首次引入 Vulkan 1.0,入口出现
Android 9(API 28) 支持实现 Vulkan 1.1
Android 10(API 29) AOSP 要求新 64 位设备最低支持 Vulkan 1.1
Android 13(API 33) 起始设备须支持 Vulkan 1.3
Android 14 及以后 CDD 要求所有支持 Vulkan 的设备必须带 1.1 驱动
Android 16 起始设备须支持 Vulkan 1.4

4.1.1 六台阶版本时间线图

Vulkan 版本台阶与 Android 版本的对应
图 5-13Vulkan 版本台阶与 Android 版本的对应:从 Android 7 的 1.0 起步,逐台阶抬到 Android 16 的 1.4,官方要求逐级抬升@ 本系列绘制

这条时间线的用法:它把"我这台设备能不能用 Vulkan 的某个特性"变成一个两步可查的问题,第一步看 Android 版本(决定"该不该有"),第二步看驱动报的 apiVersion(决定"实际有没有")。两项都对上,才谈得上用。这个"两步核对"正是下一章的核心判据。

这里得补两条容易混的口径。第一条是"要求"的主语不同:CDD 里那些"须支持 1.3 / 1.4"的条款,主语是起始设备——也就是某个 Android 版本之后新上市的设备;它并不回头要求存量设备升级驱动。第二条是"驱动门槛"另有单独一条:从 Android 14 起,所有支持 Vulkan 的设备必须带 1.1 驱动。两条合起来才解释得通一个常见现象:同一代 Android 上,新机和旧机的 Vulkan 版本可以差出两个台阶。

那官方为什么要逐级抬升?因为它撬动的是厂商必须实现的特性集合。Vulkan 每抬一个大版本,都会把一批扩展收进核心,变成"可以假定存在"的必需项。于是"设备报 1.3"这句话的实际含义不是"数字更大",而是"有一批特性你有权假定它存在"。这也是本篇要花整章讲版本的原因:它决定了你写代码时能假定什么、必须探测什么。

至于那个大整数怎么读,写成参数式更清楚:

apiVersion = major<<22 | minor<<12 | patch
其中:major 占最高位段、minor 占中段、patch 占最低 12 位
      判定时先看 major/minor,patch 一般只影响补丁级差异

读取顺序也有讲究:应用可以先调 vkEnumerateInstanceVersion 问"loader 支持到哪一版",再在创建实例时声明自己要用哪一版,最后从物理设备属性里读"设备实际报哪一版"。三次询问问的是三个不同的对象,所以三个答案可以合法地互不相同——这正是 §4.2 那六个版本号并存的技术根据。

4.2 一个重要的现实:同一台机器上会同时读出六个版本号

好了,讲了半天时间线,落回现实就会撞上一件非常反直觉的事:同一台机器的同一个时刻,你能读到六个彼此不一致的 Vulkan 版本号,而且它们各自都"没说谎"。

它们的来源不同,报的是六件不同的事:

报数来源 读取位置 数值 解码
宿主声明的 CPU Vulkan 版本 ro.cpuvulkan.version 4210688 1.4.0
dumpsys gpu 的 cpuVulkanVersion 同上同源 4210688 1.4.0
dumpsys gpu 的 vulkanVersion guest 侧实际加载到的 4206592 1.3.0
cmd gpu vkjson 顶层 apiVersion 加载器实例自报 4210688 1.4.0
cmd gpu vkjson 设备侧 apiVersion SwiftShader ICD 自报 4202496 1.2.0
宿主启动日志 guestVulkanMaxApiVersion 模拟器给 guest 设的上限 — 1.3.0(并明说 "Vulkan 1.4 不启用")

六个号,1.4.0 出现了三次、1.3.0 两次、1.2.0 一次,外加日志里一句"1.4 不启用"。它们不是在吵架,而是在报六件不同的事:宿主声明、guest 上限、加载器实例、设备自身。把它们当"版本就该是一个数"来看,就会晕;把它们当成"每个组件报自己的",反而一秒就顺了。

4.2.1 同一台机器上的六版本号实测面板

同一台机器上的六个 Vulkan 版本号
图 5-14本机多层 Vulkan 版本号实测:一次冷启动内 ro.cpuvulkan.version=1.4.0、dumpsys gpu 的 vulkanVersion=1.3.0、vkjson 设备侧=1.2.0、guest 上限=1.3.0 并存,六个号各报各的事@ 本机实测(Pixel_7 AVD,Android 16)

4.3 apiVersion 的编码与判定顺序

OK,要读懂上面那串数字,先要知道 Vulkan 版本号是怎么编码成一个大整数的。官方规则:

encoding(major, minor, patch) = VK_MAKE_API_VERSION(0, major, minor, patch)
                              = major<<22 | minor<<12 | patch

用这条规则把三个数各验算一次:

  • 4210688 = 1<<22 + 4<<12(1<<22=4194304,4<<12=16384,相加正好 4210688)→ 1.4.0;
  • 4206592 = 1<<22 + 3<<12(3<<12=12288)→ 1.3.0;
  • 4202496 = 1<<22 + 2<<12(2<<12=8192)→ 1.2.0。

三次都成立,这套编码不是摆设,是真能反推的。

4.3.1 apiVersion 编码结构图

apiVersion 的编码与两步判定
图 5-15apiVersion 的编码:major<<22 | minor<<12 | patch,高位 major、中位 minor、低位 patch;读取时先对 Android 版本门槛,再对驱动 apiVersion@ 本系列绘制

判定顺序也就清楚了:先看 Android 版本门槛(该不该有),再看驱动 apiVersion 的 major/minor(有没有),两项核对后才决定能不能用某个特性。本机那个"1.4.0 与 1.3.0 并存"的现象,离开这段编码解释是没法圆回来的。

口径警示 ⑤:六个版本号各说各话,是本系列至今最典型的"多个来源对不上"案例。本篇不把它们合并成一个数,也不外推,而是用上面那张表把"谁报的哪个号"分开列,这正是研究阶段那条"没人说清默认是谁"的答案面。


五、后端怎么被选中:切换实验

OK,这是本篇最核心、也最可能出硬证据的一章。前面讲的是"加载链"这种偏静态的东西;这一章讲"渲染后端怎么被选中",并且亲手做一次能复现的切换实验。

5.1 后端选择:RenderPipelineType 与两条 pipeline

Android 的界面渲染(HWUI)在初始化时,会按一个属性决定用哪条渲染管线。源码路径在 CanvasContext::create,它按 Properties::getRenderPipelineType() 在两条 pipeline 里选一条:SkiaVulkanPipeline 或 SkiaOpenGLPipeline。这个判断来自《Android 渲染——重温硬件加速下 Vulkan 篇》(已读全文,基于 Android 12 源码)。

5.1.1 后端选择流程分叉图

后端选择流程
图 5-16后端选择流程:getRenderPipelineType() 判定后分叉成 SkiaVulkanPipeline / SkiaOpenGLPipeline 两条,最终都交手给 Surface 与 BufferQueue@ 本系列绘制

驱动这条选择的开关,就是一个调试属性:debug.hwui.renderer。可取值如 skiagl(Skia + OpenGL)与 skiavk(Skia + Vulkan)。这也是社区里"改个属性切后端"传说的源头。

5.2 实验:把后端从 OpenGL 切到 Vulkan,读同一字段

实验设计:用一个字段做判据,就是 dumpsys gfxinfo 里的 Pipeline= 那一行。切换前读一次、切换后读一次、再切回读一次,三态对照。

先在默认态(skiagl)读:

Pipeline=Skia (OpenGL)
(gfxinfo 全文 12,398 字节,含完整 GPU 缓存段、View 树)

执行 setprop debug.hwui.renderer skiavk,读回属性确认写进去了,然后强制停止并重新拉起目标应用,再读同一字段,结果:

(gfxinfo 全文只剩 146 字节,连 Pipeline= 那一行都不存在)

看到吗?Pipeline= 那一行消失了。 但先别急着下"切换失败"的结论,因为"字段读不到"有个必须排除的可能:它可能不是没生效,而是被测对象根本没走到那一步。这就需要一个关键的旁证:dumpsys gpu 里的 Vulkan 计数。

5.2.1 后端切换实验的三态对照

后端切换实验的三态对照
图 5-17后端切换实验三态对照:skiagl → Pipeline=Skia (OpenGL)(12,398 B);skiavk → 无 Pipeline 行(146 B);再切回 skiagl → 恢复@ 本机实测(Pixel_7 AVD,Android 16)

5.3 最关键的一处证据:Vulkan 设备其实建起来了

OK,排除了"属性没写进去"(读回 skiavk,rc=0)之后,真正让结论翻转的是同一个采样窗口里 dumpsys gpu 的计数:

计数项 切换前(skiagl) 切换后(skiavk)
app_createdVulkanDevice 0 1
app_cpuVulkanInUse 0 1
vkLoadingCount 0 33

5.3.1 dumpsys gpu 的 Vulkan 计数对照

dumpsys gpu 的 Vulkan 计数对照
图 5-18dumpsys gpu 同窗口计数对照:切到 skiavk 后 app_createdVulkanDevice 0→1、app_cpuVulkanInUse 0→1、vkLoadingCount 0→33,证明 Vulkan 设备确实被创建了,不是属性没写进去@ 本机实测(Pixel_7 AVD,Android 16)

这三个 0→1 是本章最硬的一句读数,它们证明:属性确实写进去了,Vulkan 设备也确实被创建了(app_createdVulkanDevice 从 0 变成 1)。所以"切换失败"这个说法是错的。真相是:切换是"生效"的,Vulkan 设备也真的建起来了,但应用在启动那一瞬就崩溃了,所以 Pipeline= 那一行根本没来得及被打出来。

5.4 崩溃的因果链:五层,每一层都有句子作证

于是"为什么 Pipeline= 读不到"的答案,落进一条完整的五层因果链,每一层都能在 logcat 里指认:

第 1 层,属性写入。 setprop debug.hwui.renderer skiavk,读回 skiavk,rc=0,写进去了。

第 2 层,Vulkan 设备建成。 dumpsys gpu 计数 0→1,设备真建了。

第 3 层,取图失败。 W/HWUI: Error reading image: -30001!(25 秒窗口内 ×74)。

第 4 层,surface 包装失败。 E/HWUI: SkSurfaces::WrapAndroidHardwareBuffer failed(×74)、E/HWUI: VulkanSurface::dequeueNativeBuffer called with an invalid surface!(×74)——两行合计 148 条,即每轮崩溃各出一次。

第 5 层,应用侧空指针崩溃。 java.lang.NullPointerException: Attempt to invoke virtual method 'int android.graphics.Bitmap.getHeight()' on a null object reference,栈顶 at com.android.launcher3.icons.DotRenderer.<init> → FATAL EXCEPTION: main → signal 9 (Killed)。

5.4.1 skiavk 崩溃的五层因果链 logcat

skiavk 崩溃的五层因果链
图 5-19skiavk 崩溃的五层因果链:取图失败(-30001)→ surface 包装失败 → Bitmap.getHeight() on null → DotRenderer.<init> 抛 NPE → 进程被杀,五层逐层可指认@ 本机实测(Pixel_7 AVD,Android 16)

5.4.2 skiavk 崩溃循环计数

这个循环有多频繁?用 logcat -c 清零后开一个 25 秒窗口统计:FATAL EXCEPTION: main ×74、has died: fg TOP ×74、signal 9 (Killed) ×74、E/HWUI ×148、D/vulkan ×148。约每 0.34 秒一轮:拉起、一瞬崩溃、被杀、再拉起。

skiavk 崩溃循环计数
图 5-20skiavk 崩溃循环计数:25 秒窗口内 FATAL EXCEPTION ×74、has died: fg TOP ×74、signal 9 ×74、E/HWUI 148 条(两行各 74),约每 0.34 秒一轮崩溃重启@ 本机实测(Pixel_7 AVD,Android 16)

这段因果链是本篇最硬的一处证据,它把"切了没生效"这句含糊话,换成五个可指认的读数。同时必须说清两件事:

一是应用不是"渲染慢",是"取不到图"。所以这条链绝不能读成"Vulkan 性能差",它读的是"这条路在本机走不通"。走不通的根因与本机环境吻合:guest 版本上限 1.3、SwiftShader 软实现、合成不走 Vulkan(§7.2)。

二是它不能外推为真机结论。本机是模拟器;真机上 skiavk 可能是正常工作的(抓帧工具甚至要主动 setprop debug.hwui.renderer skiagl 把 HWUI 的 Vulkan 关掉,见 §8.3 旁证)。"本机启动即崩"是这台模拟器的产物。

5.5 驱动在栈里的位置:一张外部对照图

还差一张位置图,给"驱动侧变薄"补上。用 Wikimedia Commons 的Linux AMD 图形驱动栈图(1280×720,CC BY-SA 4.0),它含 Vulkan 层,用来回答"驱动到底在哪一层"。沿着图从上往下,应用在最上,Vulkan/GL 中间 API 居中,Mesa 与内核驱动依次往下,最下面是硬件。我们说"驱动侧变薄",指的就是这张图中间靠下的那一段:框架与 Mesa 把原本驱动要"猜、补、修"的动作替了,应用侧接手更多。它是通用驱动栈,不是 Android 专属,所以本篇只借它承担位置示意,不当作 Android 栈的全貌。

Linux 图形驱动栈
图 5-21Linux 图形驱动栈:应用 → Vulkan/GL → Mesa → 内核驱动 → 硬件,「驱动侧变薄」指的是这一层框架侧少做事、由应用与 Mesa 承担更多@ Wikimedia Commons · ScotXW(CC BY-SA 4.0)

这张图也有边界要说清:它是通用 Linux 驱动栈,不是 Android 专属。Android 的对应关系要换几个名字——内核驱动这一层在 Android 上由厂商 GPU 内核模块(KGSL / Mali kbase 一类)承担,Mesa 的位置由厂商 HAL 与 ICD 库顶上,而"应用"那一格在本篇讲的场景里是 HWUI 的 Skia 后端。形状可借,名字不可套,这是本篇引用外部对照图的一贯口径。


六、验证层与调试开关

先来看看这一章:它很短,只讲"入口怎么开调试的绿灯",不讲验证层内部怎么实现(那是《图形性能剖析工具链》的题域)。

6.1 验证层的开关树

验证层(validation layer)是挂在加载链上的额外库,用于在开发期抓 API 用错。它有三种部署方式:打包进 APK、改全局属性、按应用指定,各自的打开位置不同。我把 NDK 的《Vulkan 验证层》给出的可操作路径,整理成下方这张开关树,按部署方式分叉,读者照着自己的调试场景走对应分支即可。

  • 打包进 APK:把 libVkLayer_khronos_validation.so 放进 jniLibs/<abi>/;
  • 外部加载:全局走 setprop debug.vulkan.layers;
  • 按应用:settings put global enable_gpu_debug_layers 1 + gpu_debug_app + gpu_debug_layers;
  • 单一层名:统一用 VK_LAYER_KHRONOS_validation。

再多说两句"它能查什么、代价是什么",因为这两点决定了它只能待在开发期。

能力上,VK_LAYER_KHRONOS_validation 主要管四类事:参数与用法校验(枚举值越界、结构体链写错、必需项没填)、对象生命周期(谁在谁之前销毁、句柄用完有没有释放)、同步校验(读写同一资源时的栅栏是否缺失,这一项开销最大、通常要单独打开)、以及最佳实践提示(不是错,但是会让你的应用在真机上吃亏的写法)。这四类校验如果自己做,等于在驱动里重写一遍;交给验证层,代价就被移出了日常运行期。

代价上,它只该用于开发期。验证层要拦下每一次 API 调用再做检查,还要跟踪对象与同步,开销可观(具体幅度取决于开了哪几项校验)。更要紧的是它默认不随应用分发:把层库打包进 APK 会撑大包体,而在不受信任的设备上加载额外库本身就带风险——这也正是宿主会在启动日志里明写"禁掉隐式层"的原因(第 43 行,见 §7.2)。

6.1.1 验证层开关树图

验证层的开关树
图 5-22验证层开关树:打包进 APK(jniLibs)/外部加载(setprop debug.vulkan.layers)/按应用启用(enable_gpu_debug_layers + gpu_debug_layers)三路,按部署方式分叉@ 本系列绘制

6.2 实测:debug 属性的可写性自查

上面这些开关大多是 debug.* / global 属性。一个现实问题是:shell 身份到底能不能写这些属性? 拿本机自查一下(所有权属检查,不属于"层被加载"演示):

$ setprop debug.test.zz 1; getprop debug.test.zz
1
$ setprop debug.hwui.renderer skiavk; getprop debug.hwui.renderer
skiavk

6.2.1 debug 属性可写性自查面板

debug 属性的可写性自查
图 5-23debug 属性的可写性自查:shell 身份写 debug.test.zz 与 debug.hwui.renderer 都能读回(rc=0);同时 adb root 拿不到(ro.debuggable = 0)@ 本机实测(Pixel_7 AVD,Android 16)

这个自查其实回答了第五章实验的一个前置疑问:如果 setprop 被权限挡住,第五章那个实验根本做不了。本机 debug.* 在 shell 下可写,所以切换能落进去。注意这里只查了"属性可写",没有演示"层被加载",因为宿主日志第 43 行明写隐式层被禁用,如实登记。


七、实测:本机这台设备的 Vulkan 入口

好了,把前面几章讲的概念,落回本机这台设备上一次读净。这一章全部是本机真图(cmd gpu vkjson、模拟器限制、RenderEngine 段),是十一章里真图密度最高的一章。

7.1 cmd gpu vkjson:设备上报了什么

OK,本机有一条命令能一次性拿到驱动自报的设备信息:cmd gpu vkjson。它输出的有效 JSON 有 42,694 字节,关键字段:

"apiVersion": 4210688                       # loader 实例自报 = 1.4.0
"device": {
    "apiVersion": 4202496                   # SwiftShader ICD 自报 = 1.2.0
    "driverName": "SwiftShader driver"
    "deviceName": "SwiftShader Device (LLVM 10.0.0)"
    "deviceType": 4                          # VK_PHYSICAL_DEVICE_TYPE_CPU
    "vendorID": 6880
    "deviceID": 49374
}

7.1.1 vkjson 设备上报面板

cmd gpu vkjson 上报的设备信息
图 5-24cmd gpu vkjson 上报的设备信息:driverName: SwiftShader driver、deviceType: 4(CPU 类型)、vendorID: 6880;顶层 apiVersion 1.4.0 与设备侧 1.2.0 不是同一个号@ 本机实测(Pixel_7 AVD,Android 16)

留意三点。第一,deviceType: 4 是 VK_PHYSICAL_DEVICE_TYPE_CPU,正是设计指南里警告要避免的"软件模拟"类型。第二,工具输出里自带一句 vkjson is deprecated, and will be replaced in a future release,说明命令本身在弃用路上,但它现在是能用的。第三,这正好和 §4.2 那张六个版本号表对上:顶层 1.4.0(loader 报的)、设备侧 1.2.0(ICD 报的),是同一份 JSON 里就存在的两个号。

既然拿到了这份 JSON,就顺手把几个字段逐项读一遍——它们各自的读法并不一样:

  • deviceType 是枚举不是自由值:0 = 其他、1 = 集成 GPU、2 = 独立 GPU、3 = 虚拟 GPU、4 = CPU。只有 0 到 3 才谈得上"有 GPU";读到 4,说明这条 Vulkan 路径全部由软件实现,任何性能类结论都不适用。
  • driverName / deviceName 是给人看的字符串,不承担判据。它们随厂商实现变化,不能拿来做 if driverName == ... 这类判断;要判就用 vendorID / deviceID 这类数字。
  • apiVersion 出现两次,含义不同:顶层是 loader 实例自报的,设备段里是 ICD 自报的。1.4.0 与 1.2.0 并存,根因就在这。
  • driverVersion 是厂商自己的编码,规范没有规定它的位域——它只在同一厂商内部可比,跨设备比较没有意义。

再看 dumpsys gpu 那组加载计数,它们的语义也各不相同,值得分清:

字段 读的是谁 本机默认态
glLoadingCount OpenGL ES 驱动被加载的次数 1
angleLoadingCount ANGLE 被加载的次数 0
vkLoadingCount Vulkan 驱动被加载的次数 0
driverVersionCode 驱动包版本号 0

默认态下 vkLoadingCount = 0 是个关键读数:它说明这台机器在没被要求走 Vulkan 之前,连 Vulkan 驱动都还没加载过。这与第五章"切到 skiavk 之后 vkLoadingCount 变成 33"构成了完整的前后对照——那个 0→33 不是随机波动,而是"驱动真的被拉起来了"的直接证据。

7.2 模拟器的三处硬限制

先来看看这台设备为什么 Vulkan 被"关小了"?答案不在设备里,在宿主启动日志里。抓出三处硬限制:

第42行  INFO | Vulkan 1.4 is not supported for the guest and won't be enabled
第63行  INFO |     guestVulkanMaxApiVersion: 1.3.0
第55行  INFO |     useVulkanComposition: false
第56行  INFO |     useVulkanNativeSwapchain: false

7.2.1 模拟器硬限制面板

模拟器把 Vulkan「关小」了
图 5-25模拟器把 Vulkan「关小」了:宿主启动日志里的四处(guest 版本上限 1.3.0、SwiftShader 软实现、合成不走 Vulkan、隐式层被禁用),这几行只出现在宿主侧@ 本机实测(宿主 emulator_boot.log,500,223 字节)

这三行解释了本篇几乎所有"反直觉"的读数:版本上限 1.3 解释了为什么驱动自报 1.2、guest 侧加载到 1.3;useVulkanComposition: false 解释了为什么合成侧不走 Vulkan(下节再对一次)。模拟器把 Vulkan 关小了这件事,只写在宿主日志里,设备侧单看是看不出来的,这正是它值得警惕的地方。

7.3 SurfaceFlinger 的 RenderEngine 段

好了,合成侧也没有放掉,要确认 SurfaceFlinger 有没有 Vulkan 路径,直接读它自己吐出来的 dumpsys SurfaceFlinger 的 RenderEngine 段。这一段会把合成器内部是否启用 Vulkan 渲染、缓存了多少纹理缓冲、编译了多少个着色器、运行时挂了几个特效一次列清,是我们判断"合成侧走不走 Vulkan"的第一手读卡:

vulkan_renderengine: false
RenderEngine tracked buffers: 18
RenderEngine shaders cached since last dump: 134
RenderEngine runtime effects: 5

7.3.1 RenderEngine 的 Vulkan 段面板

SurfaceFlinger 的 RenderEngine 段
图 5-26SurfaceFlinger 的 RenderEngine 段:vulkan_renderengine: false,与宿主侧的 useVulkanComposition: false 对齐,合成侧不走 Vulkan@ 本机实测(Pixel_7 AVD,Android 16)

这是"合成侧为何不走 Vulkan"的宿主+设备双侧证据:宿主日志一行 useVulkanComposition: false,设备 dumpsys 一行 vulkan_renderengine: false,两边对上了,比只贴一侧可信。

7.4 一处必须写进正文的口径警示:为什么本篇不做性能对比

OK,看完上面这些,你会理解为什么本篇刻意不做 Vulkan vs OpenGL 的性能对比:因为本机上一个对照实验只会得到"Vulkan 起不来"(§5.4),那是模拟器环境产物,不是 API 结论。把"软实现、版本上限 1.3、合成不走 Vulkan"这台机器的表现,写成"Vulkan 比 OpenGL 慢/快",等于用一台报废的靶子替整个 API 下结论。

模拟器结论一律不得写成 Android 通例。 这是本系列红线;凡是需要真实 GPU 路径、真实 overlay、真实性能数字的结论,如实标"需真机"(HWC overlay → ep08,多刷新率/ARR → ep11,都需真机)。


八、五处流传很广的说法,逐条核

OK,收集资料时遇到不少流传很广的说法,本篇把它们逐条拿到本机或官方文字上核一遍。口径:每条都给"流传说法 → 出错处 → 实证/官方依据 → 本篇结论"。

8.1 五条说法一览

五处流传说法的逐条对照
图 5-27五处流传说法的逐条对照:原文、出错点、依据、本篇写法四列并排,逐条独立核@ 本系列绘制

先把收集过程中遇到的五条流传说法汇总成一张对照表:每条都给"流传说法原文、它到底错在哪一步、拿什么实证或官方文字去核、本篇最终怎么落笔"四列。这样读者不用翻正文也能一眼看到五处结论各自站哪、依据是什么;逐条独立核验,不把几条说法含糊捏成一段。

8.2 说法的分辨

说法一:「Vulkan 一定比 OpenGL 快」。 出错在把"API 更现代"直接等同"更快",忽略场景。正面两条反证:教程那句"有些场景两者没有任何差别"(§3.4 已引),以及"不是自动更快"的免责段。本篇结论:Vulkan 提供的是减 CPU 组织开销的工具,不是自动的加速包。

说法三:「改个属性就白嫖性能」。 出错在把后端切换说成性能提升,且该路径可能需 root。本篇用一个实验把这个说法拆穿了一半:改属性确实能切(§5.3 设备 0→1 为证),但"切换生效"和"能用"是两件事,本机切完是启动即崩。切换成功 ≠ 变快,收益依设备与驱动而定。

说法四:「ANGLE 就是驱动」/「ANGLE 已是默认的 ES 实现」。 出错在把翻译层当驱动、把"可选"当"默认"。本篇按官方口径写:ANGLE 是跑在 Vulkan 之上的可选层、不是驱动(§1.3 已钉死)。

关于说法五「Vulkan 不需要驱动」,诚实说明:本轮没有找到把它写明文的中文文章,所以本篇不为它单独立靶。只在术语处把话说全:驱动仍由 GPU 厂商提供并做硬件映射,Vulkan 变的只是框架侧变薄(§3.3、§5.5)。

说法二(一串精确收益百分比):Vulkan 单核开销降低 30%–50% 这类数字无出处、不可核对。本篇一律不引用,改为定性表述(§3.4 已贯彻)。

把这五条的正确写法各收成一句,可以直接拿去对照。Vulkan 比 OpenGL 快这条,正确写法是"Vulkan 把削减 CPU 组织开销的工具交给你,用不用得上取决于场景与实现";单核开销降三到五成这类数字,正确写法是"无源数字不引用,要论证请给出可复现的机型、场景与采样方法";改个属性就白嫖性能这条,正确写法是"属性只决定哪条路被选中,选中不等于走得通,更不等于变快";ANGLE 就是驱动这条,正确写法是"ANGLE 是运行在 Vulkan 之上的可选翻译层,驱动仍由 GPU 厂商提供";Vulkan 不需要驱动这条,正确写法是"驱动仍由厂商提供并做硬件映射,变的是框架侧把更多决定权交给了应用"。

原理上讲,这五条错的是同一件事:把"机制层的替换"读成"结果层的提升"。一个 API 换了模型,与"你的应用在你这台机器上更快了"之间,隔着场景、实现与环境三重大门。§8.3 那条抓帧工具的旁证是同一机制的另一面:它暗示的默认后端不是本机的默认后端,因为"默认值由谁写下"这件事本身就跟环境绑定。

8.3 一个值得单独摆出来的旁证:抓帧工具主动切回 GL

收集资料时挖到一条特别值得写的旁证:LunarG 的抓帧指南为了抓帧,要求先 setprop debug.hwui.renderer skiagl 把 UI 的 Vulkan 关掉。这说明:在它面对的机型上,HWUI 默认走的是 Vulkan。而本机实测默认是 Skia (OpenGL)。

这不矛盾,而是"三层不一致"的又一例:工具文档暗示的真机默认(Vulkan)≠ 本机默认(GL)。结合第四章,我们得到一条完整的不一致链:文档写主导底层 API 是 Vulkan、抓帧工具暗示 HWUI 默认走 Vulkan、本机却钉成 GL,而且同一个驱动栈里同时有六个版本号。 本篇把这些不一致全部摆上台面,而不是抹平。

8.4 商标与素材边界

与"说法"关系不大,但属于素材纪律:Commons 上 Vulkan/Khronos/OpenGL 的 logo 全是注册商标(标 PD 只因为低于原创性门槛,商标权没放弃),所以本篇不使用任何 logo 文件。logo 对技术论证没有贡献,反而挤掉一张机制图,还有商标风险。

8.5 本机后端结论:默认 GL,且切换启动即崩

OK,把上面几条说法编成本机这一台设备的后端结论:本机默认 renderer 是 skiagl(OpenGL 后端),且冷启动后读 debug.hwui.renderer 仍是 skiagl;根因在宿主启动日志第 75 行 androidboot.debug.hwui.renderer=skiagl,是模拟器在内核命令行上钉死的。 强行切到 skiavk 会启动即崩(§5.4 五层链)。

8.5.1 本机冷启动默认后端结论面板

冷启动后的默认后端
图 5-28冷启动后的默认后端:debug.hwui.renderer 读到 skiagl,根因在宿主启动日志第 75 行 androidboot.debug.hwui.renderer=skiagl@ 本机实测(Pixel_7 AVD,Android 16)

这里的"通用默认"与"本机默认"必须分开写:真机出厂多按 Vulkan 走(§8.3 旁证),本机是 GL,是因为模拟器在 boot args 上把 HWUI 后端钉成了 skiagl。本机这一台不能代表真机出厂默认。


九、四项可执行动作 + 下一篇

好了,把理论收进可操作的命令上。给四项动作,每条都给命令与判定阈值。

9.1 四项动作与判据

动作一:查本机 Vulkan 版本与驱动。

getprop ro.hardware.vulkan          # 查 ICD 名
getprop ro.cpuvulkan.version        # 宿主声明版本(需解码)
cmd gpu vkjson                      # 驱动自报(注意 deviceType 可能是 CPU)
dumpsys gpu | grep -i vulkan        # 加载计数与版本

判据:deviceType != 4(不是 CPU 软实现)时,版本号才谈得上有"硬件性能"的含义。

动作二:查本机默认渲染后端。

dumpsys gfxinfo <pkg> | grep Pipeline

判据:在有界面的应用上应能读到一行 Pipeline=<后端>。读不到=被测对象没走到那一步,先别断言"没生效",去查崩溃日志。

动作三:想做后端切换实验。 先确认 shell 能否写 debug.*(§6.2 的自查法),再 setprop debug.hwui.renderer skiavk,改完必须读同字段对照(§5.2),并且成功与失败都要记录,失败也要能写出因果链。

动作四:需要验证层时,走正确的开关树。 jniLibs 打包 / debug.vulkan.layers 全局 / enable_gpu_debug_layers + gpu_debug_layers 按应用。判据:确认层真的被加载了再宣称"层生效",被禁用的环境要如实标注。

9.1.1 四项可执行动作与判据面板

四项可执行动作与判据
图 5-29四项可执行动作与判据:查版本、查后端、做切换、开验证层,每条命令 + 一个「何时才算成立」的判据@ 本系列绘制

9.2 本篇的结论

好了,把十章收成三句。

第一句:Vulkan 是一条"入口",问"我的设备走哪条入口"比背"Vulkan 更现代"更有用。 加载链、版本门槛、后端选择,这三样决定了本机到底是什么在跑。

第二句:入口的"生效"和"能用"是两件事。 本机用实验证明了这一点:属性写进去了、Vulkan 设备也真建起来了(app_createdVulkanDevice 0→1),但应用启动即崩。凡是从文档/社区/真机给出的结论,落到本机都要用一个可复现的读数去对。

第三句:模拟器与真机分开写。 本机的 Vulkan 是被"关小"的模拟层,六个版本号并存、合成不走 Vulkan、切到 skiavk 启动即崩,这些全是这台环境的事,一条都不能外推成 Android 通例。

未解的挑战也留一句:HWUI 默认到底走哪个后端、"走通"的条件是什么,在本机只能答到"设备建成但走不通",真正要答清需要一台走 Vulkan 后端正常的真机,并配合 §8.3 那种"抓帧工具主动切回 GL"的旁证。这部分留给需要真机的 ep11/ep13 与后续自跑篇。

9.3 下一篇预告

OK,下一篇回到链路的主线,讲缓冲交出去之后,系统侧在 Vsync 之后到底做了什么:SurfaceFlinger 怎么决定每一层要不要合成、用 GPU 还是用硬件叠加,以及它是怎么在刷新率对齐的时间里把一帧送上屏幕的。


参考资料

  1. 使用 Vulkan 处理图形
  2. 实现 Vulkan
  3. Vulkan(Android 架构入口)
  4. 图形总览
  5. NDK 图形入门
  6. 原生引擎支持
  7. Vulkan 设计指南
  8. Vulkan 验证层
  9. frameworks/native/vulkan/(源码入口)
  10. frameworks/native/vulkan/libvulkan/
  11. hardware/include/hardware/hardware.h
  12. Khronos Vulkan 基础样本
  13. Android N 中的 Vulkan 支持
  14. Android 渲染——重温硬件加速下 Vulkan 篇
  15. Vulkan 教程 18 · 阶段性总结
  16. 理解 Vulkan 的核心优势
  17. AOSP 课程 · Vulkan
  18. SurfaceFlinger 系列 · RenderEngine
  19. ANGLE 与 Android 11
  20. Qualcomm · 移动端 Vulkan
  21. Skia 讨论组 · 后端切换
  22. 某第三方实操帖 · 在 Galaxy 上启用 Vulkan
  23. LunarG · GFXR Android 抓帧指南
  24. 某魔改包站点
  25. open-awesome · ANGLE
  26. Wikimedia Commons · Vulkan 简化管线图
  27. Wikimedia Commons · Linux AMD 图形驱动栈图
  28. Wikimedia Commons · CPU 与 GPU 分工图

相关链接 - 上一篇:【Android 图形显示系统】(四) BufferQueue:图形缓冲区的邮局 - 下一篇(预告):【Android 图形显示系统】(六) SurfaceFlinger:在 Vsync 之后做什么 - 相关系列:实时渲染管线探析 | 移动端图形与功耗 | GPU 架构剖析 | 图形性能剖析工具链 | 2D 矢量引擎深挖

With Best Wishes.