这一块是对点九图的简单介绍,如果对这块已经有了解的话,可以直接跳到2,看看聊天气泡中如何使用点九图。
首先简单介绍下点九图出现的原因吧,Android为了使用同一张图作为不同数量文字的背景,设计了一种可以指定区域拉伸的图片格式“.9.png”,这种图片格式就是点九图。 注意:这种图片格式只能被使用于Android开发。在ios开发中,可以在代码中指定某个点进行拉伸,而在Android中不行,所以在Android中想要达到这个效果,只能使用点九图。(对大多数时候来说是这样,实际上可以自己构造,后面会稍微提一下,见3.2)
点九图的本质实际上是在图片的四周各增加了1px的像素,并使用纯黑(#FF000000)的线进行标记,其它的与原图没有任何区别。可以参考以下图片:
可以看到在该图的四周,均有黑色像素标记,这些标记的作用分别是:
由于点九图的本质也是个图片,只是在周围加了1px的像素,所以你可以使用ps或其它任意支持像素操作的p图工具来将一个普通图片转换为点九图,但是就易用性和可视性来看,推荐使用Draw9patch工具,该工具存在于早期的Android SDK中,如今被集成到了Android studio中,它实际上也是在图片边缘画线,但是在工具中只能在边缘画,且只能画黑线,这样便减少了误操作的可能性。并且在Draw9patch中可以预览结果。 注意:图片四个角的像素点不要画上黑线,否则Android无法识别。
具体如何操作,这里就不多赘述了。
Android中使用点九图,主要有三种形式,使用res文件夹中的点九图,使用assets文件夹中的点九图以及使用网上拉取的点九图,下面分别看看它们如何使用。
Android并不是直接使用点九图,而是在编译时将其转换为另外一种格式(见3.1),这种格式是将其四周的黑色像素保存至Bitmap类中的一个名为mNinePatchChunk的byte[]中,并抹除掉四周的这一个像素的宽度;接着在使用时,如果Bitmap的这个mNinePatchChunk不为空,且为9patch chunk(见3.3),则将其构造为NinePatchDrawable,否则将会被构造为BitmapDrawable,最终设置给view,NinePatchDrawable的拉伸主要是通过其draw方法实现的。总而言之,最后打出的包中的点九图,已经不是原来的带黑线的点九图了。
先简单说下从网上拉取点九图的过程,首先使用url请求网络数据,并将结果缓存为本地文件,再使用文件流创建Bitmap,接着使用Bitmap创建drawable再交给view使用,最后由view的draw方法调用drawable的draw方法将图片绘制出来。 再看看上面1.5的解析原理,它会带来一个坑,由于聊天气泡需求需要使用url从网络上拉取点九图,如果这个点九图没有经过编译的过程,将其周围的黑线标记放入到png中的一个辅助chunk中,那么在使用这个图作为背景时,会显示出黑线,且不会拉伸。而根据以往的经验,Android是可以直接使用点九图的,因为放到res文件夹中就可以直接使用,所以就将点九图直接上传到服务器上,这时从网上拉取的图片数据是带黑线的图,那么就会出错了。 这时候效果是这样的:
emmmmm,很丑。
当初发现这个问题时,考虑了三个方案来处理
pass: 其实客户端还有一个解决方法,就是自己根据拉伸区域构造mNinePatchChunk,然后将普通的Bitmap创建为NinePatchDrawable,因为ios的特性,设计会指定一个拉伸点,以及文字显示区域,这两个数据是固定的,也就是说,每个点九图上的黑线是固定的,所以可以根据这些数据来构造一个固定的mNinePatchChunk。这样可以做出一个跟ios实现方式相同的控件。(见3.2)
最后是通过联系手q参考并采用了他们的方案,也就是上面的第一种方法实现的。 (为了避免外包同学出错后无法发现问题,这里如果不是点九图,则上报,用于发现问题)
最终确定的实现流程如下图所示:
接下来说说这9个步骤中的遇到问题:
步骤2中,给9点图画黑线,必须是纯黑色像素,且图片的四个角必须为透明像素点,否则Android会无法识别,且在步骤3中将无法转换。
步骤3中,将带黑线的点九图转换,可以使用Android SDK自带的aapt工具进行转换,使用命令aapt c -v -S . -C .\9out
,其中.表示当前目录,.\9out表示目标目录,即将当前目录中的带黑线的点九图转换后放到当前目录下的9out文件夹中,9out文件夹该命令会自动创建。为了让外包自动化这个过程,可以将其做成一个工具,用于批量转换。
步骤4中,上传的过程中不能对转换后的点九图进行压缩(某些配置平台会默认对上传的图片进行压缩),因为转换后的点九图的黑线信息被保存到了png图片的辅助数据块中,这部分数据在压缩过程中会消失,导致最终客户端通过url拉到的图片不是点九图,从而显示错误。
步骤4中,某些cdn因为省流量,或者其它原因,对图片进行压缩或者转码为webp格式,这样会导致最终通过url拉取的图片不是想要的点九图,从而显示错误。这里要针对不同业务采取不同的处理方式,这里简单说说K歌这里的处理方式,用于借鉴。 首先介绍下目前K歌使用webp的方案:
1. 客户端http请求如果带了accept:image/webp,则服务器认为需要webp,此时会转一份webp格式图片出来,后续请求给客户端的是webp格式图片。
2. 如果http请求里不带webp参数,且图片url是/0(表示原图)结尾,则服务器不会压缩。 所以要保证最终url拉到的图片不是webp格式,且不被压缩,有两个条件:
1. 在这类拉点九图url请求的请求头里不带上accept:image/webp。
2. 拉点九图的url的末尾以/0结尾。
步骤8中,需要通过Bitmap创建drawable,如果是使用的res文件,Android系统自己会完成这个过程,而如果是网上拉取的图片,则需要自己创建,这部分代码如下:
byte[] chunk = bitmap.getNinePatchChunk();
if (NinePatch.isNinePatchChunk(chunk)) {
NinePatchDrawable ninePatchDrawable = new NinePatchDrawable(bitmap, chunk, new Rect(), null);
}
else {
BitmapDrawable bitmapDrawable = new BitmapDrawable(bitmap)
}
这里要看看这个chunk信息是怎么被构造的,以及如何判断这个chunk是不是点9chunk的。这个后面再讲。
6. 步骤9中,一定要使用缓存,不然异步加载的过程中,在list中显示会有问题,跳变很严重。有的图片加载组件不支持NinePatchDrawable缓存的记得要补上。
7. 步骤8或9中,为了避免外包同学出错后无法发现问题,或者出现问题4中所说的压缩和格式转换导致出错,所以这里如果不是点九图,则进行上报,用于发现问题。
先来一小段分析: 根据之前的讨论我们知道,画黑线的点九图与普通图片的区别主要在于四周多了1px的黑线,而转换后的点九图则没有这1px的黑线,但是它却包含了用于拉伸的信息,那么这个信息是被包含在哪里呢?这里就要看看png图片的文件格式了。 png图片是由一个png文件标志和三个以上的数据块(chunk)按照特性的顺序组成,它含有两种类型的数据块,关键数据块和辅助数据块,关键数据块只包含文件头、尾数据块和图像数据块,是必须要有的,而辅助数据块则是可选的。包含了一些额外的信息,每个数据块包含哪些信息可以参考文章PNG文件结构分析,这里就不多说了。 PNG文件结构如下
现在可以知道,点九图的黑线,在编译时,被转换成了某些数据,保存在了png图片的辅助数据块中了。 那么,这个数据块是什么样的,java的Bitmap又是如何解析出这个数据块的呢?通过追查,可以找到这块代码,其中mPatch最终将被构造到Bitmap中去。
// frameworks\base\core\jni\android\graphics\NinePatchPeeker.cpp
bool NinePatchPeeker::readChunk(const char tag[], const void* data, size_t length) {
if (!strcmp("npTc", tag) && length >= sizeof(Res_png_9patch)) {
Res_png_9patch* patch = (Res_png_9patch*) data;
size_t patchSize = patch->serializedSize();
if (length != patchSize) {
return false;
}
// You have to copy the data because it is owned by the png reader
Res_png_9patch* patchNew = (Res_png_9patch*) malloc(patchSize);
memcpy(patchNew, patch, patchSize);
Res_png_9patch::deserialize(patchNew);
patchNew->fileToDevice();
free(mPatch);
mPatch = patchNew;
mPatchSize = patchSize;
} else {
...
}
return true; // keep on decoding
}
通过这块代码可以知道,系统是找到tag为“npTc”的数据块,如果这个数据块没有异常的话,就将这个数据块的数据复制给mPatch,最终被装入到Bitmap中。
这里有个Res_png_9patch结构,所以Bitmap的mNinePatchChunk的数据结构实际上为Res_png_9patch,第一个字节用来表示这个png图片是否是点九图,上述的NinePatch.isNinePatchChunk()方法也是通过这个字节判断的,接着就是一些拉伸点的位置和padding信息,用于最后的渲染流程。
//frameworks\base\libs\androidfw\include\androidfw\ResourceTypes.h
struct alignas(uintptr_t) Res_png_9patch
{
Res_png_9patch() : wasDeserialized(false), xDivsOffset(0),
yDivsOffset(0), colorsOffset(0) { }
int8_t wasDeserialized;
uint8_t numXDivs;
uint8_t numYDivs;
uint8_t numColors;
// The offset (from the start of this structure) to the xDivs & yDivs
// array for this 9patch. To get a pointer to this array, call
// getXDivs or getYDivs. Note that the serialized form for 9patches places
// the xDivs, yDivs and colors arrays immediately after the location
// of the Res_png_9patch struct.
uint32_t xDivsOffset;
uint32_t yDivsOffset;
int32_t paddingLeft, paddingRight;
int32_t paddingTop, paddingBottom;
enum {
// The 9 patch segment is not a solid color.
NO_COLOR = 0x00000001,
// The 9 patch segment is completely transparent.
TRANSPARENT_COLOR = 0x00000000
};
// The offset (from the start of this structure) to the colors array
// for this 9patch.
uint32_t colorsOffset;
...
inline int32_t* getXDivs() const {
return reinterpret_cast(reinterpret_cast(this) + xDivsOffset);
}
inline int32_t* getYDivs() const {
return reinterpret_cast(reinterpret_cast(this) + yDivsOffset);
}
inline uint32_t* getColors() const {
return reinterpret_cast(reinterpret_cast(this) + colorsOffset);
}
} __attribute__((packed));
这里简单讲下这个结构中每个字段代表的含义:
再看看这些字段是如何生效的,首先看看一段源码中的注释:
* This chunk specifies how to split an image into segments for
* scaling.
*
* There are J horizontal and K vertical segments. These segments divide
* the image into J*K regions as follows (where J=4 and K=3):
*
* F0 S0 F1 S1
* +-----+----+------+-------+
* S2| 0 | 1 | 2 | 3 |
* +-----+----+------+-------+
* | | | | |
* | | | | |
* F2| 4 | 5 | 6 | 7 |
* | | | | |
* | | | | |
* +-----+----+------+-------+
* S3| 8 | 9 | 10 | 11 |
* +-----+----+------+-------+
*
* Each horizontal and vertical segment is considered to by either
* stretchable (marked by the Sx labels) or fixed (marked by the Fy
* labels), in the horizontal or vertical axis, respectively. In the
* above example, the first is horizontal segment (F0) is fixed, the
* next is stretchable and then they continue to alternate. Note that
* the segment list for each axis can begin or end with a stretchable
* or fixed segment.
* /
正如源码注释中所示,点九图将图片虚拟地划分成了n个模块,其中F区域代表固定,S区域代表拉伸,而mDivX,mDivY描述了所有S区域的位置起始位置和结束位置,mColor描述了各个小模块的颜色,大小为n,通常情况下,赋值为Res_png_9patch.NO_COLOR。就以源码注释中的例子来说,mDivX,mDivY,mColor如下:
mDivX = [ S0.start, S0.end, S1.start, S1.end];
mDivY = [ S2.start, S2.end, S3.start, S3.end];
mColor = [c[0],c[1],...,c[11]]
这时之前的问题就解决了,这个数据块就是tag为”npTc“的数据块,数据内容为 Res_png_9patch。Java的Bitmap通过遍历png的数据块,找出tag为”npTc“且长度无误的数据块,就是点九图的数据块,这个数据块保存了点九图的拉伸信息,主要是定义了拉伸区域以及padding。
最后来看看之前的几个问题:
将png图片中四周黑线所代表的信息解析成Res_png_9patch,存放到png的一个数据块中,然后把黑线抹去,黑线所表示的信息就保存在了如上的Res_png_9patch结构中。
理论上是可行的,可以根据Res_png_patch的结构,构造一个chunk[],将所需要的拉伸信息和padding填入到需要的位置上,接着在构造NinePatchDrawable的时候,将这个chunk[]信息传入进去即可。 其中拉伸信息因为ios端也需要,所以后台会传,或者设计定好一个位置写死,而padding也是设计给的,实际上这个padding会被view本身设置的padding所覆盖。
NinePatchDrawable的构造方法为NinePatchDrawable ninePatchDrawable = new NinePatchDrawable(bitmap, chunk, new Rect(), null);
,其中bitmap直接用解析出来的bitmap,chunk则是从bitmap.getNinePatchChunk()取出的一个chunk,或者是客户端自己构造的一个byte[],allocate一个ByteBuffer,然后根据Res_png_9patch的结构,依次填入数据即可。参考文章2有一个小demo,有兴趣的可以跳转看看。
这里的mNinePatchChunk信息,实际上是在编译时,编译器将png图片中四周黑线所代表的信息解析成Res_png_9patch,存放到png的一个数据块中,然后j将tag设置为“npTc”,接着在使用时,通过遍历png的数据块,找到tag为“npTc”的数据块,如果这个数据块没有问题,这被用作参数构造Bitmap,最终成为mNinePatchChunk。 判断一个经过tag和长度筛选后的chunk信息是否是点9chunk信息,是直接通过Res_png_9patch.wasDeserialized判断的,可以看看NinePatch的isNinePatchChunk的代码,如果wasDeserialized不为-1,则表示这个信息是点9chunk信息。
static jboolean isNinePatchChunk(JNIEnv* env, jobject, jbyteArray obj) {
if (NULL == obj) {
return JNI_FALSE;
}
if (env->GetArrayLength(obj) < (int)sizeof(Res_png_9patch)) {
return JNI_FALSE;
}
const jbyte* array = env->GetByteArrayElements(obj, 0);
if (array != NULL) {
const Res_png_9patch* chunk = reinterpret_cast(array);
int8_t wasDeserialized = chunk->wasDeserialized;
env->ReleaseByteArrayElements(obj, const_cast(array), JNI_ABORT);
return (wasDeserialized != -1) ? JNI_TRUE : JNI_FALSE;
}
return JNI_FALSE;
}
参考:
高级UI之自定义控件,打造可拖曳的QQ消息气泡
平常在工作之余,我喜欢去总结在工作中遇到的一些技术问题和收集一些相关的学习文档进行学习,大家如不嫌弃,可拿去做参考文档学习 通过此处↓↓↓,内容有Android 基础知识点、Framework与UI、性能优化、音视频学习手册、APP架构知识点、Jetpack Compose、Android 车载开发学习手册、Flutter等……
有需要的可以复制下方链接,传送直达!!!
https://qr21.cn/CaZQLo?BIZ=ECOMMERCE