NinePatch 的绘制原理,还能用多久
NinePatch 的绘制原理,还能用多久
Android 开发里有个老东西,从 1.0 时代活到现在,几乎每个项目都用过,但真去深究它怎么工作的开发者没几个。NinePatch,就是那个 .9.png 后缀的图,Google 官方文档叫它 "nine-patch drawable"。2024 年了,Jetpack Compose 已经大面积铺开,矢量图、SVG、甚至纯代码绘制的 UI 越来越常见,NinePatch 这种位图时代的产物到底还有没有存在的必要?我最近在维护一个老项目的资源体系,顺便把 NinePatch 的源码、工具链、以及现代替代方案翻了个底朝天,有些发现挺有意思。
NinePatch 的核心机制:那 1px 黑线到底画了什么
NinePatch 的本质是一种元数据编码方案。你在一张 PNG 的四周画上 1 像素宽的黑线,Android 的资源打包工具 aapt2 在编译期读取这些黑线,生成一个二进制结构存进 .arsc 或者单独的 .9.png 文件里。运行时 NinePatchDrawable 解析这个结构,决定怎么拉伸、怎么填内容。
具体说,顶部和左边的黑线定义的是 "stretchable area",也就是可拉伸区域。右边和底部的黑线定义的是 "padding area",也就是内容放置区域。这个设计非常紧凑,1 像素黑线既不浪费存储,又对人类可读——至少对设计师可读。
我扒了一下 Android 源码里 NinePatch.cpp 的实现,在 frameworks/base/libs/hwui 路径下能找到。NinePatch 的绘制核心在 Res_png_9patch 结构体,定义在 frameworks/base/include/androidfw/ResourceTypes.h。这个结构体用了一个很省空间的编码:把黑线段序列化成 int8_t 数组,记录每段黑线的起始位置和长度。比如顶部黑线如果是一条连续的从 x=10 到 x=90,就编码为 [10, 80]。如果是不连续的多个拉伸区,就依次追加。
运行时绘制走的是 Skia 的 SkCanvas::drawNinePatch,在 SkNinePatch.cpp 里。Skia 会把整张图切成 3x3 的九宫格,四个角原样绘制,四条边按拉伸区缩放,中间区域按拉伸区缩放。这个 "九宫格" 就是名字来源,虽然实际拉伸区可以有多个,不一定严格是九格。
有个细节很多人没注意:NinePatch 的拉伸是 纯最近邻插值,没有 bilinear 过滤。源码里 SkNinePatch::DrawMesh 明确用了 kNone_SkFilterQuality。这意味着如果你的拉伸区里有渐变或者细线,放大后会出现明显的锯齿或色带。这是位图拉伸的先天限制,不是 bug,但确实让 NinePatch 在高 DPI 屏幕上显得很糙。
工具链考古:draw9patch 和它的现代替代品
Google 官方提供的 NinePatch 编辑工具叫 draw9patch,藏在 Android SDK 的 tools 目录下。这个工具是个 Swing 写的 Java 桌面程序,界面丑得像是 2005 年的产物——确实也差不多是那个年代的代码。功能很基础:打开 PNG,画黑线,预览拉伸效果,保存为 .9.png。
draw9patch 有个致命问题:它不会校验你的黑线是否合法。比如你在顶部画了两条不连续的黑线,左边只画了一条,这种组合在某些 Android 版本上会导致 NinePatchDrawable 构造失败,抛出 IllegalArgumentException。错误信息通常是 "Array length must be divisible by 2",因为段数编码对不上。我在 Android 10 的设备上复现过这个崩溃,堆栈指向 android.graphics.NinePatch$NativeStruct.<init>。
更隐蔽的坑是透明度。如果你的 PNG 带有 alpha 通道,黑线必须用纯黑 #000000,任何灰度值都会被 aapt2 忽略。draw9patch 的预览里看起来是黑线,但导出后灰线丢失,导致拉伸区失效。这个问题在官方 Issue Tracker 上有记录,issue 编号 37041592,状态至今是 "Won't Fix (Intended Behavior)"。
因为官方工具太难用,社区出现了几个替代方案。我最喜欢的是 NinePatch-Editor,一个开源的 Web 工具,GitHub 地址是 github.com/jackwakefield/NinePatch-Editor。它用 Canvas API 在浏览器里实现了一模一样的预览逻辑,支持拖拽画黑线,实时显示拉伸效果。最重要的是它会做合法性校验,非法组合会标红提示。
但这个工具也有局限:它只能处理 32-bit PNG,Indexed Color 的 PNG 上传后会丢失调色板信息。另外它不支持批量处理,如果你有几十张切图要转 NinePatch,得一张张手动操作。
批量处理我推荐 Android Asset Studio 的 NinePatch 生成器,网址是 romannurik.github.io/AndroidAssetStudio/nine-patches.html。这是 Google 设计师 Roman Nurik 写的 Web 工具,可以批量上传、统一设置拉伸区、导出全套 DPI 资源。它的预览逻辑比 draw9patch 更准确,特别是 content padding 的显示很直观。
Roman Nurik 的这个工具是 2014 年写的,代码基于 Polymer 0.5,现在已经有点古董了。去年我尝试本地部署时,发现 bower install 会直接失败,因为 Bower 已经 deprecated。我 fork 了一份改成 npm 依赖,花了大概两小时,有兴趣的可以搜我的 GitHub,同名仓库。
aapt2 的编译细节:为什么你的 .9.png 突然失效
NinePatch 的黑线不是在运行时动态解析的,而是编译期就被 aapt2 处理掉。这个细节决定了它的行为边界。
aapt2 compile 阶段会调用 NinePatch::NinePatch 构造函数,位于 frameworks/base/tools/aapt2/util/NinePatch.cpp。它会扫描 PNG 边缘的 1px 黑线,生成 Res_png_9patch 结构,然后把黑线从图像数据中抹掉——最终打进 APK 的 .9.png 实际上是没有黑线的,黑线信息存在文件头部的二进制元数据里。
这意味着两件事。第一,你不能在运行时动态创建 NinePatch,除非你自己实现完整的编码逻辑。第二,如果你的构建流程里有自定义的资源处理,比如用 ImageMagick 压缩 PNG,一定要确保压缩在 aapt2 compile 之后执行,否则黑线被提前抹掉,aapt2 就找不到拉伸区了。
我在一个项目里踩过这个坑。团队为了减小 APK 体积,在 Gradle 里加了 pngquant 的压缩任务,配置在 mergeResources 之后、processResources 之前。结果所有 .9.png 的拉伸区全部失效,UI 上线后严重变形。排查了整整一天,最后发现 pngquant 把黑线当成普通像素一起量化了,颜色值从 #000000 变成了 #010101,aapt2 的阈值判断失败。
解决方案是把 pngquant 移到 processResources 之后,或者对 .9.png 单独排除。更稳妥的做法是用 aapt2 自带的 PNG 压缩,android.enableAapt2=true 默认就会做,质量足够好。
还有一个编译期的坑:Android Gradle Plugin 7.0 之后,aapt2 对 NinePatch 的校验变严格了。以前能编译通过的非法黑线组合,7.0 会直接报错。具体是 AGP 4.2 升 7.0 那次,因为 aapt2 版本从 4.2.0 跳到了 7.0.0,内部重构了 NinePatch 解析器。错误信息很模糊:"Error: found an invalid configuration"。如果你在升级 AGP 时遇到这个,检查所有 .9.png 的黑线是否连续、是否纯黑、是否有多余的透明像素。
矢量时代的冲击:VectorDrawable 和 ShapeDrawable
NinePatch 最大的竞争对手不是别的位图格式,是矢量。
VectorDrawable 从 API 21 开始支持,AppCompat backport 到 API 14。它的核心优势是无限缩放不失真,一个文件适配所有 DPI。对于简单的几何形状——圆角矩形、带箭头的气泡、不规则卡片——用 VectorDrawable 完全不需要 NinePatch 那套拉伸区机制。
但 VectorDrawable 有个性能天花板:复杂路径的 rasterization 开销很大。Android 的矢量渲染在 hwui 线程里做,路径太复杂会导致帧率下降。Google 官方的建议是单个 VectorDrawable 的 path 数据控制在 2000 个点以内。我在 Pixel 6 上做过测试,一个 5000 点的矢量图,简单缩放动画能掉到 45fps,而等效的 NinePatch 能稳 60fps。这是因为 NinePatch 的绘制走 Skia 的 drawBitmapNine,纯位图 blit,GPU 友好。
所以我的判断是:性能敏感的场景,NinePatch 仍有优势。比如列表项的背景、频繁复用的按钮样式、需要带动画的浮层。这些场景用 VectorDrawable 要小心,用 NinePatch 更稳妥。
另一个替代方案是 ShapeDrawable,特别是 GradientDrawable。从 API 24 开始,GradientDrawable 支持更复杂的形状定义,包括圆角、渐变、stroke,甚至 setPadding 方法。很多原本需要 NinePatch 实现的 "圆角矩形+内容区" 效果,现在可以用纯代码的 GradientDrawable 完成。
但 GradientDrawable 的圆角有个长期 bug:四个角的 radius 在 API 29 之前不能独立设置,只能统一一个值。API 29 加了 setCornerRadii 方法,但老版本兼容要额外处理。NinePatch 没有这个问题,因为圆角是画在位图里的,和系统版本无关。
Jetpack Compose 的立场:NinePatch 被边缘化了吗
Compose 的资源体系完全重写了。Image composable 支持 painterResource(R.drawable.xxx),对 .9.png 的加载走 BitmapPainter,底层还是 NinePatchDrawable,所以兼容性没问题。但 Compose 的声明式 UI 范式里,NinePatch 的 "内容区 padding" 概念变得很尴尬。
在传统 View 体系里,NinePatch 的 padding 会自动设置到 View 的 padding 属性,这是 View.setBackgroundDrawable 里的逻辑。但 Compose 没有 View,Modifier.padding 和背景绘制是分离的。你用 painterResource 加载一个 NinePatch 当背景,内容 padding 不会自动生效,必须手动再写一遍 Modifier.padding(start=..., top=...)。这个重复定义很烦人,而且设计师改了切图后,代码里的 padding 值不会同步变。
Compose 官方推荐的做法是用 BoxWithConstraints 配合 Modifier.drawBehind 自己画,或者用 MaterialTheme.shapes 里的 RoundedCornerShape。Shapes 在 Compose Material 库里定义,本质是 CornerBasedShape,运行时用 Outline 和 Path 绘制,和 VectorDrawable 的渲染路径类似。
我实测过 Compose 1.5.0 的性能,RoundedCornerShape 做列表项背景的渲染开销,比等效的 NinePatch 大约高 15%-20%。数据来自 Macrobenchmark 的 FrameTimingMetric,测试设备 Pixel 7,Compose BOM 2023.08.00。差距不算大,但大规模列表里能感知到。
Compose 1.6.0 之后有个新 API AsyncImage 在 Coil 库里,支持占位图和错误图的 NinePatch 背景。Coil 是 coil-kt.github.io/coil 这个开源库,目前最主流的 Compose 图片加载方案。它的 AsyncImage 内部对 NinePatch 的处理有点 trick:先把 NinePatchDrawable 转成 Bitmap,再包成 BitmapPainter。这个转换会丢失 NinePatch 的动态拉伸能力,变成固定尺寸的位图。如果你的 NinePatch 拉伸区设计得很宽,用 AsyncImage 当背景会变形。这是 Coil 2.4.0 版本的已知行为,GitHub issue #1802 有讨论,维护者 Colin White 的回复是 "NinePatch is not a priority for Compose image loading"。
现代设计工具的输出:Figma 和 NinePatch 的断层
现在设计师的主流工具是 Figma,但 Figma 没有原生输出 NinePatch 的功能。社区有个插件叫 Android NinePatch Export,Figma 插件 ID 1038609384494143164,作者是个人开发者,不是 Google 官方。
这个插件的原理很粗暴:在 Figma 里画一个带圆角的矩形,插件自动在四周加上 1px 黑线,导出 PNG。但它不支持复杂拉伸区,比如气泡对话框那种尾巴形状,插件画不出来。而且 Figma 的导出是 @1x 基准,你需要自己再缩放成 mdpi/hdpi/xhdpi 多套资源,或者交给 Android Studio 的 Create 9-Patch file 功能处理。
我对比过手动流程和插件流程,一个典型的聊天气泡背景,手动在 draw9patch 里调要 10 分钟,Figma 插件导出后还需要在 Android Studio 里微调,总时间差不多。插件的价值主要是减少设计师和开发的交接摩擦,让设计师自己出第一版 .9.png。
更现代化的方案是跳过 NinePatch,直接用 Figma 的 Dev Mode 输出 Compose 代码。Figma 官方在 2023 年推出的这个功能,可以把设计稿里的形状直接转成 Modifier.background + RoundedCornerShape 的 Kotlin 代码。月费 12 美元的专业版才能用 Dev Mode 的完整功能,免费版只能看不能导出代码。
这个方案的局限很明显:它只能处理纯色、渐变、简单圆角,复杂位图效果——比如带纹理的背景、照片边框——完全无能为力。NinePatch 在这种场景下仍然不可替代,除非你愿意把纹理也做成可平铺的 shader,那复杂度就上去了。
游戏引擎视角:NinePatch 在 Unity、Godot 里的借尸还魂
跳出 Android 原生开发,NinePatch 的概念在其他平台也有回响。Unity 的 UI Toolkit 里有 NineSliceSprite,Godot 的 NinePatchRect 节点,机制一模一样:九宫格拉伸,保留边角,缩放中间。
Unity 的 NineSliceSprite 在 2021.2 版本加入 UI Toolkit,之前只能用第三方的 9-slice shader 实现。它的元数据不是黑线编码,而是在 Sprite Editor 里手动填 Border 数值:L、T、R、B 四个边距。这和 Android 的 NinePatch 等价,只是交互方式不同。
Godot 的 NinePatchRect 更直接,属性面板里有 patch_margin_left 等四个值,运行时动态调整。Godot 4.1 之后还支持 axis_stretch_horizontal 的 TILE 模式,中间区域可以平铺而不是拉伸,这是 Android NinePatch 没有的功能。如果你的背景是条纹纹理,平铺比拉伸效果好得多。
这些跨平台的 NinePatch 变体说明了一个问题:九宫格拉伸是一种通用的 UI 需求,不是 Android 特有的。但 Android 的黑线编码方案确实是最紧凑、最省事的,其他平台要么需要手动填数值,要么需要额外配置文件。从这一点看,Android 的 NinePatch 设计有它的历史价值。
我的实际项目决策:什么时候还用它,什么时候放弃
我目前维护的项目是一个社交 App,日活几百万,代码库从 2015 年积累到现在。资源体系里有大概 400 多张 .9.png,分布在各个模块。去年我们做了一次全面的资源治理,结论是:
保留 NinePatch 的场景:聊天气泡、红包封面、活动弹窗背景。这些的共同点是形状不规则、带有品牌定制的纹理效果、需要精确的内容区控制。特别是红包封面,用了烫金纹理,矢量完全无法还原,NinePatch 是唯一选择。
迁移到 VectorDrawable 的场景:所有按钮背景、输入框背景、简单卡片。这些形状规则,没有复杂纹理,矢量一个文件适配全 DPI,显著减少 APK 体积。我们统计过,迁移了 200 张左右的 NinePatch 到矢量,APK 减小 1.8MB。
迁移到 Compose 纯代码的场景:新项目模块直接用 MaterialTheme.shapes + Modifier.background,不再出切图。老模块逐步重构,不主动改。
工具链上,我们保留了 draw9patch 做最后的微调,但主力设计输出走 Figma + 那个 Android NinePatch Export 插件。构建流程里严格禁止 pngquant 处理 .9.png,用 aapt2 自带的压缩。
NinePatch 的寿命预测:至少还有五年
Google 对 NinePatch 的态度很明确:不淘汰,不增强。Android 14 的源码里,NinePatchDrawable 的实现和 Android 5.0 相比几乎没有结构性变化,只有些性能优化和 bugfix。Compose 里也没有计划做 NinePatch 的 first-class 支持,Colin White 的回复代表了社区的主流看法。
但 NinePatch 也不会消失。Android 的兼容性承诺太强,几百万个 App 的资源依赖不可能打破。而且 NinePatch 解决的问题——位图的九宫格拉伸——在矢量无法完全覆盖的场景下仍然真实存在。
我个人的判断是:NinePatch 会长期作为 "遗留但可用" 的状态存在,类似今天的 AsyncTask 或者 Handler。新项目应该优先矢量或纯代码,老项目没必要大规模迁移,但新需求不要再增加 NinePatch 的复杂度。工具链上,官方 draw9patch 迟早会被废弃,社区工具或者 IDE 内置功能会接管。
如果你现在要从零学习 Android UI,NinePatch 的了解优先级可以排很后面。但如果你在维护一个有历史包袱的项目,或者在做游戏、工具类需要复杂位图效果的 App,NinePatch 的底层原理值得花半天时间读一遍源码。那 1px 黑线的编码设计,是早期 Android 团队在资源受限环境下做的精巧工程,这种设计思想比技术本身更耐读。