扒一扒Android的.9图

1,329 阅读11分钟

前言

相信大家对.9图都不陌生,我们在开发当中当有控件的背景需要对内容的大小做自适应的时候,可能就需要用到.9图。如下图所示,就是一张.9图。官方是这么定义的:

NinePatchDrawable 图形是一种可拉伸的位图,可用作视图的背景。Android 会自动调整图形的大小以适应视图的内容。NinePatch 图形是标准 PNG 图片,包含一个额外的 1 像素边框。必须使用 9.png 扩展名将其保存在项目的 res/drawable/ 目录下。

ninepatch_raw.png

那么有人可能会说,这有什么好讲的,从做Andorid开始,我就一直用到现在了。但是,往往越简单的东西,我们越容易忽略它。下面我们就带着这几个问题,一步步来看:

  1. Android是怎么识别一张.9图的?
  2. .9图片一定要放在res/drawable目录下吗,Android是怎么处理它的,为什么在手机上显示出来这个黑色边线却不见了?
  3. 一定要用.9图才能达到自适应的效果吗,普通图片行不行?

PNG

定义

从官方介绍可以得知,.9图是一张标准的PNG图片,只不过是加了一些额外的像素而已,那么首先我们得了解一下什么是PNG。

便携式网络图形(英语:Portable Network Graphics,PNG)是一种支持无损压缩的位图图形格式,支持索引、灰度、RGB三种颜色方案以及Alpha通道等特性。PNG的开发目标是改善并取代GIF作为适合网络传输的格式而不需专利许可,所以被广泛应用于互联网及其他方面上。

文件结构

文件跟协议一样,都是用数据来呈现的。那么既然协议有协议头来标识是什么协议,文件也一样。PNG的文件标识(file signature)是由8个字节组成(89 50 4E 47 0D 0A 1A 0A, 十六进制),系统就是根据这8个自己来识别出PNG文件。

在文件头之后,紧跟着的是数据块。PNG的数据块分为两类,一类是PNG文件必须包含、读写软件也必须要支持的关键块(critical chunk);另一种叫做辅助块(ancillary chunks),PNG允许软件忽略它不认识的附加块。这种基于数据块的设计,允许PNG格式在扩展时仍能保持与旧版本兼容。

数据块的格式:

名称字节数说明
Length4字节指定数据块中数据域的长度,其长度不超过(2^{31}-1)字节
Chunk Type Code(数据块类型码)4字节数据块类型码由ASCII字母(A-Z和a-z)组成
Chunk Data(数据块实际内容)实际内容长度存储按照Chunk Type Code指定的数据
CRC(循环冗余检测)4字节存储用来检测是否有错误的循环冗余码

关键块中有4个标准的数据块:

  • 文件头数据块IHDR(header chunk):包含有图像基本信息,作为第一个数据块出现并只出现一次。
  • 调色板数据块PLTE(palette chunk):必须放在图像数据块之前。
  • 图像数据块IDAT(image data chunk):存储实际图像数据。PNG数据允许包含多个连续的图像数据块。
  • 图像结束数据IEND(image trailer chunk):放在文件尾部,表示PNG数据流结束。

当然关于PNG的信息不止这些,有兴趣了解更多的话,可以去阅读RFC 2083,这里不做过多赘述。

所以不难猜出,.9图是在PNG的辅助块加了自己可以识别的数据块,然后显示的时候对图片做特殊的处理

Android是怎么加载一张.9图的

在Android中,一张图片对应的是一个Bitmap,我们可以看看从怎么从文件读取一张Bitmap入手

//BitmapFactory.javapublic static Bitmap decodeFile(String pathName) {
    return decodeFile(pathName, null);
}
​
private static native Bitmap nativeDecodeStream(InputStream is, byte[] storage,
            Rect padding, Options opts, long inBitmapHandle, long colorSpaceHandle);

我们根据一个文件路径读取一张图片的话,需要调用BitmapFactorydecodeFile方法,这里我省略了一些过程,但最终都会调用到nativeDecodeStream这个方法,它是一个native方法,接着看C++那边是怎么实现的

//BitmapFactory.cpp
​
static jobject nativeDecodeStream(JNIEnv* env, jobject clazz, jobject is, jbyteArray storage,
        jobject padding, jobject options, jlong inBitmapHandle, jlong colorSpaceHandle) {
    ...
​
    if (stream.get()) {
        ...
        bitmap = doDecode(env, std::move(bufferedStream), padding, options, inBitmapHandle,
                          colorSpaceHandle);
    }
    return bitmap;
}
​
static jobject doDecode(JNIEnv* env, std::unique_ptr<SkStreamRewindable> stream,
                        jobject padding, jobject options, jlong inBitmapHandle,
                        jlong colorSpaceHandle) {
    ...
    NinePatchPeeker peeker;
    std::unique_ptr<SkAndroidCodec> codec;
    {
        ...
        std::unique_ptr<SkCodec> c = SkCodec::MakeFromStream(std::move(stream), &result, &peeker);
        ...
    }
  
    ...
    jbyteArray ninePatchChunk = NULL;
    if (peeker.mPatch != NULL) {
        size_t ninePatchArraySize = peeker.mPatch->serializedSize();
        ninePatchChunk = env->NewByteArray(ninePatchArraySize);
        jbyte* array = (jbyte*) env->GetPrimitiveArrayCritical(ninePatchChunk, NULL);
        memcpy(array, peeker.mPatch, peeker.mPatchSize);
        env->ReleasePrimitiveArrayCritical(ninePatchChunk, array, 0);
    }
  
    // now create the java bitmap
    return bitmap::createBitmap(env, defaultAllocator.getStorageObjAndReset(),
            bitmapCreateFlags, ninePatchChunk, ninePatchInsets, -1);
}

doDecode方法很长,这里只提取关键部分。我们看到了关键的NinePatchPeeker,然后把它的指针传给MakeFromStream这个方法。接着copy出NinePatchPeekermPatchBitmap作为构造参数。我们接着往下看:

// SkCodec.cpp
// 刚才NinePatchPeeker传给了这个方法的第三个参数,NinePatchPeeker实际上是实现了SkPngChunkReader
std::unique_ptr<SkCodec> SkCodec::MakeFromStream(
        std::unique_ptr<SkStream> stream, Result* outResult,
        SkPngChunkReader* chunkReader, SelectionPolicy selectionPolicy) {
  
    ...
#ifdef SK_HAS_PNG_LIBRARY
    if (SkPngCodec::IsPng(buffer, bytesRead)) {
        return SkPngCodec::MakeFromStream(std::move(stream), outResult, chunkReader);
    } else
#endif
    ...
}
​
// SkPngCodec.cpp
std::unique_ptr<SkCodec> SkPngCodec::MakeFromStream(std::unique_ptr<SkStream> stream,
                                                    Result* result, SkPngChunkReader* chunkReader) {
    SkCodec* outCodec = nullptr;
    *result = read_header(stream.get(), chunkReader, &outCodec, nullptr, nullptr);
    if (kSuccess == *result) {
        // Codec has taken ownership of the stream.
        SkASSERT(outCodec);
        stream.release();
    }
    return std::unique_ptr<SkCodec>(outCodec);
}
​
static SkCodec::Result read_header(SkStream* stream, SkPngChunkReader* chunkReader,
                                   SkCodec** outCodec,
                                   png_structp* png_ptrp, png_infop* info_ptrp) {
    ...
#ifdef PNG_READ_UNKNOWN_CHUNKS_SUPPORTED
    // Hookup our chunkReader so we can see any user-chunks the caller may be interested in.
    // This needs to be installed before we read the png header.  Android may store ninepatch
    // chunks in the header.
    if (chunkReader) {
        png_set_keep_unknown_chunks(png_ptr, PNG_HANDLE_CHUNK_ALWAYS, (png_byte*)"", 0);
        png_set_read_user_chunk_fn(png_ptr, (png_voidp) chunkReader, sk_read_user_chunk);
    }
#endif
    ...
}

这里重点看下png_set_read_user_chunk_fn这个方法,传了chunkReadersk_read_user_chunk方法进去

#ifdef PNG_READ_USER_CHUNKS_SUPPORTED
void PNGAPI
png_set_read_user_chunk_fn(png_structrp png_ptr, png_voidp user_chunk_ptr,
    png_user_chunk_ptr read_user_chunk_fn) {
   ...
   png_ptr->read_user_chunk_fn = read_user_chunk_fn;
   png_ptr->user_chunk_ptr = user_chunk_ptr;
}
#endif

这个方法主要是对png_ptr的两个变量进行赋值,png_ptr是一个PNG结构体的指针。之后read_user_chunk_fn这个方法会在pngrutil.c中被调用

// pngrutil.c
void png_handle_unknown(png_structrp png_ptr, png_inforp info_ptr,
    png_uint_32 length, int keep) {
    ...
#  ifdef PNG_READ_USER_CHUNKS_SUPPORTED
   if (png_ptr->read_user_chunk_fn != NULL) {
      if (png_cache_unknown_chunk(png_ptr, length) != 0) {
         /* Callback to user unknown chunk handler */
         int ret = (*(png_ptr->read_user_chunk_fn))(png_ptr,
             &png_ptr->unknown_chunk);
      }
   }
  ...
}

这里看方法名就知道是libpng这个库在读取未知的数据块,调用了read_user_chunk_fn方法读取用户自己定义的数据块。而read_user_chunk_fn就是上面的sk_read_user_chunk

// SkPngCodec.cpp
#ifdef PNG_READ_UNKNOWN_CHUNKS_SUPPORTED
static int sk_read_user_chunk(png_structp png_ptr, png_unknown_chunkp chunk) {
    SkPngChunkReader* chunkReader = (SkPngChunkReader*)png_get_user_chunk_ptr(png_ptr);
    // readChunk() returning true means continue decoding
    return chunkReader->readChunk((const char*)chunk->name, chunk->data, chunk->size) ? 1 : -1;
}
#endif// pngget.c
#ifdef PNG_USER_CHUNKS_SUPPORTED
png_voidp PNGAPI
png_get_user_chunk_ptr(png_const_structrp png_ptr) {
   return (png_ptr ? png_ptr->user_chunk_ptr : NULL);
}
#endif

拿到一个SkPngChunkReader,而它的具体实现上面有说到,就是NinePatchPeeker

// 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 if (!strcmp("npLb", tag) && length == sizeof(int32_t) * 4) {
        mHasInsets = true;
        memcpy(&mOpticalInsets, data, sizeof(int32_t) * 4);
    } else if (!strcmp("npOl", tag) && length == 24) { // 4 int32_ts, 1 float, 1 int32_t sized byte
        mHasInsets = true;
        memcpy(&mOutlineInsets, data, sizeof(int32_t) * 4);
        mOutlineRadius = ((const float*)data)[4];
        mOutlineAlpha = ((const int32_t*)data)[5] & 0xff;
    }
    return true;
}

找了这么久,我们的目的地终于找到了。可以看到.9图对应的数据块有三个:npTcnpLbnpOl,负责图片图片拉伸的是npTc这个数据块。它在这里用一个Res_png_9patch的结构体封装,我们可以从这个结构体的注释就可以知道很多事情了,懒得看注释的话可以跳过,直接看我下面的解释:

/**
 * 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.
 *
 * ...
 *
 * The colors array contains hints for each of the regions. They are
 * ordered according left-to-right and top-to-bottom as indicated above.
 * For each segment that is a solid color the array entry will contain
 * that color value; otherwise it will contain NO_COLOR. Segments that
 * are completely transparent will always have the value TRANSPARENT_COLOR.
 *
 * The PNG chunk type is "npTc".
 */
struct alignas(uintptr_t) Res_png_9patch
{
    int8_t wasDeserialized;
    uint8_t numXDivs, numYDivs, numColors;
​
    uint32_t xDivsOffset, yDivsOffset, colorsOffset;
    
    // .9图右边和下边黑线描述的方位
    int32_t paddingLeft, paddingRight, 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
    };
    
    ...
      
    inline int32_t* getXDivs() const {
        return reinterpret_cast<int32_t*>(reinterpret_cast<uintptr_t>(this) + xDivsOffset);
    }
    inline int32_t* getYDivs() const {
        return reinterpret_cast<int32_t*>(reinterpret_cast<uintptr_t>(this) + yDivsOffset);
    }
    inline uint32_t* getColors() const {
        return reinterpret_cast<uint32_t*>(reinterpret_cast<uintptr_t>(this) + colorsOffset);
    }
}

注释告诉我们几个信息:

  • 一张图片被分为几个区块,支持拉伸的区块坐标分别存储在xDivs和yDivs两个数组。

  • S开头的表示可以拉伸(其实就是做.9图时,旁边1像素的黑线标记的范围),F表示不能拉伸。

    按照注释中的例子,图片被分为12块,例如S0,它表示编号为1、5、9在横轴方向 上是可以拉伸的,S1则表示标号3、7、11是支持拉伸的。所以xDivs和yDivs存储的数据长下面这样:

    xDivs = [S0.start, S0.end, S1.start, S1.end]

    yDivs = [S2.start, S2.end, S3.start, S3.end]

  • colors 描述了各个区块的颜色,按照从左到右从上到下表示。通常情况下,赋值为源码中定义的NO_COLOR = 0x00000001就行了

    colors = [c1, c2, c3, .... c11]

  • 横向(或者纵向)有多个拉伸块的时候,他们的拉伸长度是按照他们标识的范围比例来算的。加入S0是1像素,S1是3像素,则他们拉伸长度按照1:3去拉伸

数据结构

那么,从Res_png_9patch的序列化方法,我们可以推断出这个chunk的数据结构

void Res_png_9patch::serialize(const Res_png_9patch& patch, const int32_t* xDivs,
                               const int32_t* yDivs, const uint32_t* colors, void* outData) {
    uint8_t* data = (uint8_t*) outData;
    memcpy(data, &patch.wasDeserialized, 4);     // copy  wasDeserialized, numXDivs, numYDivs, numColors
    memcpy(data + 12, &patch.paddingLeft, 16);   // copy paddingXXXX
    data += 32;
​
    memcpy(data, xDivs, patch.numXDivs * sizeof(int32_t));
    data +=  patch.numXDivs * sizeof(int32_t);
    memcpy(data, yDivs, patch.numYDivs * sizeof(int32_t));
    data +=  patch.numYDivs * sizeof(int32_t);
    memcpy(data, colors, patch.numColors * sizeof(uint32_t));
}
名称字节长度说明
wasDeserialized1这个值为-1的话表示这个区块不是.9图
numXDivs1xDivs 数组长度
numYDivs1yDivs 数组长度
numColors1colors 数组长度
--4无意义
--4无意义
paddingLeft4横向内容区域的左边
paddingRight4横向内容区域的右边
paddingTop4纵向内容区域的顶部
paddingBottom4纵向内容区域的底部
--无意义
xDivsnumXDivs * 4横向拉伸区域(图片上方黑线)
yDivsnumYDivs * 4纵向拉伸区域(图片左边黑线)
colorsnumColors * 4各个区块颜色

小结

那么,到这里Android把一个.9图加载成Bitmap给理清楚了。先通过读取PNG到header信息,发现有npTc数据块到时候,把它到chunk数据读取出来,用来做Bitmap的构造参数。接下来我们看看绘制

绘制

.9图是用NinePatchDrawable做绘制的,使用方式是这样的:

val bitmap = BitmapFactory.decodeFile(absolutePath)
// 检查bitmap的ninePatchChunk是不是属于.9图的格式,其实就是判断这个chunk的wasDeserialized(第一个字节)是不是等于-1, 
val isNinePatch = NinePatch.isNinePatchChunk(bitmap.ninePatchChunk)
if (isNinePatch) {
    // 用bitmap以及bitmap.ninePatchChunk构造NinePatchDrawable
    val background = NinePatchDrawable(context.resources, bitmap, bitmap.ninePatchChunk, Rect(), null)
    imageView.background = background
}

NinePatchDrawable的绘制方法里,又会调用到native方法,由于篇幅原因,这里简单的列下调用栈,大家感兴趣的话可以去看源码:

NinePatchDrawable.java -> draw()
NinePatch.java -> draw()
Canvas.java -> drawPatch()
BaseCanvas.java -> drawPatch()
                -> nDrawNinePatch() // 这里是一个native方法,从这里开始就都是native逻辑了
​
SkiaCanvas.cpp -> drawNinePatch() // Canvas所有的native方法都对应的native层的SkiaCanvas。这里会根据xDivs和yDivs的数据把图片分为N个格子
SkCanvas.cpp -> drawImageLattice()
SkDevice.cpp -> onDrawImageLattice()  // 这里循环绘制每个格子
             -> drawImageRect()
SkBitmapDevice.cpp -> drawBitmapRect()  // 这里给Paint设置了BitmapShader去绘制图片,模式用的是CLAMP(拉伸模式)

到这里,从加载到绘制的过程都已经讲完了,但是还漏了一块,那就是编译。

编译

大家有没有疑问,.9图header里面,npTc这个数据块哪里来?官方介绍为什么叫我们要保存到res/drawable/里面?

其实在编译的时候,aapt会对res/drawable/的图片进行编译,发现是.9图,就把图片四周的黑色像素提取出来,整理成npTc数据块,放到PNG的header里面。

我们可以用Vim打开一张未编译的.9图看看

1.png

这里我们可以看到一些基本的数据块,例如IHDR以及IEND。接着我们用aapt编译一下这张.9图,具体命令如下:

./aapt s -i xxx_in.png -o xxx_out.png

2.png 用Vim打开之后,可以看到多了很多信息,也可以看到.9图对应的npTc数据块,打开图片也可以发现那些四周的黑线不见了。

最后

回答一下文章开头的几个问题:

.9图片一定要放在res/drawable目录下吗

这个不一定,如果你需要从assets目录、sdcard、或者网络读取.9图,也可以实现。只不过需要手动用aapt对图片做处理

一定要用.9图才能达到自适应的效果吗,普通图片行不行

通过了解.9图的原理之后,答案是肯定行的。我们可以自己手动构造ninePatchChunk, 然后传给NinePatchDrawable就可以了,这里就不写代码演示了。