你拿手机刷着刷着,突然手滑点开一张图,这图向上无限高,向下无限深,向左无限远,向右无限远,这图是什么?
是点9图。🤣
大家好,我是来颠覆你对点9图固有认知的星际码仔。
点9图几乎在每个Android工程中都或多或少地有用到,而切点9图也可以说是每个Android开发者必备的传统艺能了,但今天我们要分享的主题估计各位平时比较少接触到,就是——从网络加载点9图。
为了讲好这个主题,我们会从点9图的基础知识出发,比较网络加载方式与常规用法的区别,然后分别给出一个次优级和更优级的解决思路,可以根据你们当前项目的实际情况自由选取。
照例,先给出一张思维导图,方便复习:
其典型的一个应用就是IM中的聊天气泡框,气泡框的宽高会随着我们输入文本的长短而自适应拉伸,但气泡框资源本身并不会因拉伸而失真。
这么神奇的效果是怎么实现的呢?
答案是:四条黑线。
可拉伸区域由左侧及顶部一条或多条黑线来定义,左侧的黑色边框定义了纵向拉伸的区域,顶部的黑色边框定义了横向拉伸的区域,拉伸的效果是通过复制区域内图片的像素来实现的。
可以看到,由于可拉伸区域选择的都是比较平整的区域,而没有覆盖到四周的圆角,因此图片无论怎么纵向或横向拉伸,四周的圆角都不会因此而变形失真。
可绘制区域由右侧及底部的各一条黑线来定义,称为内边距线。如果没有添加内边距线,视图内容将默认填满整个视图区域。
而如果添加了内边距线,则视图内容仅会在右侧及底部的黑线所定义的区域内显示,如果视图内容显示不下,则图片会拉伸至合适的尺寸。
但这种情况在改成了从网络加载点9图之后有所变化。
问题在于,即使强大如Glide,对于从网络加载点9图的这种场景,也没有做很好的适配,以至于我们加载完图片之后会发现...
完!全!没!有!拉!伸!效!果!
要理解这背后的原因,我们需要把目光转移到一个原本在打包过程中常常被我们忽视的角色——AAPT。
AAPT即Android Asset Packaging Tool,是用于构建*.apk文件的Android资源打包工具,默认存放在Android SDK的build-tools目录下。
尽管我们很少直接使用AAPT工具,但其却是.apk文件打包流程中不可或缺的重要一环,具体可参照下面的.apk文件详细构建流程图。
而常规用法下的点9图之所以能正常工作,也离不开打包时,AAPT对于包含点9图在内的PNG格式图片的预处理。
那么,AAPT的预处理具体都做了哪些事情呢?
首先,我们要了解的是,在Android的世界里,存在着两种不同形式的点9图文件,分别是“源类型(source)”和“已编译类型(compiled)”。
源类型就是前面所提到的,使用了包括Draw 9-patch在内的点9图制作工具所创建的、四周带有1像素宽黑色边框的PNG图片。
而已编译类型指的是,把之前定义好的点九图数据(可拉伸区域&可绘制区域等)写入原先格式的辅助数据块后,把四周的黑色边框抹除了的PNG图片。
这里稍微提一下PNG图片的文件格式。
在文件头之外,PNG图片使用了基于“块(chunk)”的存储结构,每个块负责传达有关图像的某些信息。
块有关键块或辅助块两种类型,关键块包含了读取和渲染PNG文件所需的信息,必不可少。而辅助数据块则是可选的,程序在遇到它不理解的辅助块时,可以安全地忽略它,这种设计可以保持与旧版本的兼容性。
点九图数据所放入的,正是一个tag为“npTc”的辅助数据块。
AAPT在打包过程中对点9图的预处理,其实就是将点9图从源类型转换为已编译类型的过程,也只有已编译类型的点9图才能被Android系统识别并处理,从而达到根据视图内容自动调整图片大小的效果。
而直接从网络加载的点9图则缺少这个过程,我们实际拿到的是没有经过AAPT预处理的源类型,Android系统就只会把它当普通的PNG格式图片一样处理,因此展示时会有残留在四周的黑色边框,并且当视图内容过大时,图片就会因为不合理拉伸而产生明显的失真。
明白了这一层的原理之后,我们也就有了一个次优级别的解决思路,也即:
AAPT同时也是一个命令行工具,其在打包过程中参与的多项工作都可以通过命令行来实现。
其中就包括对PNG格式图片的预处理。
于是,具体可操作的步骤也很清晰了:
这样做还有一个好处就是,AAPT命令行工具会校验源类型点9图的规格,如果不合规就会报错并给出原因提示,这样就可以在生产端时就保证产出点9图的合规性,而不是等到展示的时候才发现有问题。
命令行如下:
[]表示是可选的完整命令或参数。
这个过程还需保证不会因流量压缩而将图片转为Webp格式,或者造成“npTc”的辅助数据块丢失。
这里主要涉及到2个问题:
这2个问题都可以从Android SDK源码中找到答案。
关于问题1,我们可以从点9图的常见应用场景,即设为视图控件背景的API入手,从View#setBackground方法一路深入直至BitmapFactory#setDensityFromOptions方法,就可以看到:
Bitmap#getNinePatchChunk方法返回的是一个byte数组类型的数据,从方法名就可以看出其正是关于点九图规格的辅助块数据:
NinePatch#isNinePatchChunk方法是一个Native函数,我们等到后面深入点九图Native层结构体时再展开讲:
而关于问题2,我们可以通过查找对Bitmap#getNinePatchChunk方法的引用,在Drawable#createFromResourceStream方法中找到一个参考例子:
可以看到,它是通过在判断NinePatchChunk数据不为空后,构建了一个NinePatchDrawable来告诉系统以点9图的形式正确处理这张图的。
于是我们可以得出结论,客户端要做的额外处理,就是在拿到已编译类型的点9图并构建为Bitmap后:
先调用Bitmap#getNinePatchChunk方法尝试获取点9图数据
再通过NinePatch#isNinePatchChunk方法判断是不是点9图数据。
如果是点9图数据,则利用这个点9图数据构建一个NinePatchDrawable
如果不是,则构建一个BitmapDrawable。
示例代码如下:
这样就满足了吗?并没有。方案本身虽然可行,但让一向习惯可视化界面操作的设计组同事执行命令行,实在是有点太为难他们了,并且每次产出资源后都要用AAPT工具处理一遍,也确实有点麻烦。
话说回来,命令行工具的底层肯定还是依赖代码来实现的,那有没有可能在客户端侧实现一套与AAPT工具一样的逻辑呢?这就引出了我们一个更次优级别的解决思路,也即:
透过上一个方案我们可以了解到,最关键的地方还是那个byte数组类型的点九图数据块(NineChunk),如果我们能知道这个数据块里面实际包含什么内容,就有机会在在客户端侧构造出一份类似的数据。
上一个方案中提到的NinePatch#isNinePatchChunk方法就是我们的突破点。
接下来,就让我们进入Native层查看isNinePatchChunk方法的源码实现吧:
可以看到,在isNinePatchChunk方法内部实际是将传入的byte数组类型的点9图数据转为一个Res_png_9patch类型的结构体,再通过一个wasDeserialized的结构变量来判断是不是点9图数据的。
这个Res_png_9patch类型的结构体内部是这样的:
很明显,这个结构体就是用来存储点9图规格数据的,我们可以根据该结构体的源码和注释梳理出每个变量的含义:
根据该结构体注释中的描述,这个结构体是用于指定如何将图像分割成多个部分以进行缩放的,其中:
以该结构体注释中的例子来说,mDivX,mDivY,mColor分别如下:
我画了一张示意图,应该会更方便理解一点:
这几个结构体变量所描述的,不正是我们源类型的点9图四周所对应的那些黑色边框的位置吗?
那么,现在我们只需要在Java层定义一个与Res_png_9patch结构体的数据结构一模一样的类,并在填充关键的变量数据后序列化为byte数组类型的数据,就可以作为NinePatchDrawable构造函数的参数了。
这里只给出使用层的示例代码:
NinePatchChunk类即为前面说的在Java层定义的类,并提供了几个静态方法用于创建NinePatchDrawable,其在内部会去检测传入的Bitmap实例属于哪种类型:
NinePatch即为已编译类型的点9图,RawNinePatch即为源类型的点9图,源类型是通过PNG图片4个角像素是否为透明且是否包含黑色边框判断的。