【图像格式篇】可以从网络加载点9图的吗?

从网络加载点9图.png

你拿手机刷着刷着,突然手滑点开一张图,
这图向上无限高,向下无限深,向左无限远,向右无限远,
这图是什么?

是点9图。

大家好,我是来颠覆你对点9图固有认知的星际码仔。

点9图几乎在每个Android工程中都或多或少地有用到,而切点9图也可以说是每个Android开发者必备的传统艺能了,但今天我们要分享的主题估计各位平时比较少接触到,就是——从网络加载点9图

为了讲好这个主题,我们会从点9图的基础知识出发,比较网络加载方式与常规用法的区别,然后分别给出一个次优级和更优级的解决思路,可以根据你们当前项目的实际情况自由选取。

照例,先给出一张思维导图,方便复习:

从网络加载点9图.png

点9图的基础知识

点9图,官方的正式名称为9-patch,是一种可拉伸的位图图像格式,因其必须以.9.png为扩展名进行保存而得名,通常被用作各类视图控件的背景。

其典型的一个应用就是IM中的聊天气泡框,气泡框的宽高会随着我们输入文本的长短而自适应拉伸,但气泡框资源本身并不会因拉伸而失真。

聊天气泡框.jpg

这么神奇的效果是怎么实现的呢?

答案是:四条黑线。

忽略掉.9.png的扩展名,点9图的本质其实就是一张标准的PNG格式图片,而与其他普通PNG格式图片的不同之处在于,点9图在其图片的四周额外包含了1像素宽的黑色边框,用于定义图片的可拉伸的区域与可绘制的区域,以实现根据视图内容自动调整图片大小的效果

可拉伸区域的定义

可拉伸区域由左侧及顶部一条或多条黑线来定义,左侧的黑色边框定义了纵向拉伸的区域,顶部的黑色边框定义了横向拉伸的区域,拉伸的效果是通过复制区域内图片的像素来实现的。

可拉伸区域.png

可以看到,由于可拉伸区域选择的都是比较平整的区域,而没有覆盖到四周的圆角,因此图片无论怎么纵向或横向拉伸,四周的圆角都不会因此而变形失真。

可绘制区域的定义

可绘制区域由右侧及底部的各一条黑线来定义,称为内边距线。如果没有添加内边距线,视图内容将默认填满整个视图区域。

没有添加内边距线.png

而如果添加了内边距线,则视图内容仅会在右侧及底部的黑线所定义的区域内显示,如果视图内容显示不下,则图片会拉伸至合适的尺寸。

添加了内边距线.png

Glide能处理点9图吗

点九图的常规用法,就是以.9.png为扩展名保存在项目的 res/drawable/ 目录下,并随着项目一起打包到 *.apk 文件中,然后跟其他普通的PNG格式图片一样正常使用即可。

但这种情况在改成了从网络加载点9图之后有所变化。

问题在于,即使强大如Glide,对于从网络加载点9图的这种场景,也没有做很好的适配,以至于我们加载完图片之后会发现...

完!全!没!有!拉!伸!效!果!

焯.gif

要理解这背后的原因,我们需要把目光转移到一个原本在打包过程中常常被我们忽视的角色——AAPT。

AAPT是什么?

AAPT即Android Asset Packaging Tool,是用于构建*.apk文件的Android资源打包工具,默认存放在Android SDK的build-tools目录下。

尽管我们很少直接使用AAPT工具,但其却是.apk文件打包流程中不可或缺的重要一环,具体可参照下面的.apk文件详细构建流程图。

*.apk文件详细构建流程图.png

流程里,AAPT工具最重要的功能,就是获取并编译我们应用的资源文件,例如AndroidManifest.xml清单文件和Activity的XML布局文件。 还有就是生成了一个R.java,以便我们从 Java 代码中根据id索引到对应的资源。

而常规用法下的点9图之所以能正常工作,也离不开打包时,AAPT对于包含点9图在内的PNG格式图片的预处理。

那么,AAPT的预处理具体都做了哪些事情呢?

AAPT对点九图做的预处理

首先,我们要了解的是,在Android的世界里,存在着两种不同形式的点9图文件,分别是“源类型(source)”和“已编译类型(compiled)”。

源类型就是前面所提到的,使用了包括Draw 9-patch在内的点9图制作工具所创建的、四周带有1像素宽黑色边框的PNG图片。

ic_bubble_right.9.png

而已编译类型指的是,把之前定义好的点九图数据(可拉伸区域&可绘制区域等)写入原先格式的辅助数据块后,把四周的黑色边框抹除了的PNG图片。

ic_bubble_right.png

这里稍微提一下PNG图片的文件格式。

Png文件结构.png

在文件头之外,PNG图片使用了基于“块(chunk)”的存储结构,每个块负责传达有关图像的某些信息。

块有关键块辅助块两种类型,关键块包含了读取和渲染PNG文件所需的信息,必不可少。而辅助数据块则是可选的,程序在遇到它不理解的辅助块时,可以安全地忽略它,这种设计可以保持与旧版本的兼容性

点九图数据所放入的,正是一个tag为“npTc”的辅助数据块。

AAPT在打包过程中对点9图的预处理,其实就是将点9图从源类型转换为已编译类型的过程,也只有已编译类型的点9图才能被Android系统识别并处理,从而达到根据视图内容自动调整图片大小的效果。

而直接从网络加载的点9图则缺少这个过程,我们实际拿到的是没有经过AAPT预处理的源类型,Android系统就只会把它当普通的PNG格式图片一样处理,因此展示时会有残留在四周的黑色边框,并且当视图内容过大时,图片就会因为不合理拉伸而产生明显的失真。

四周残留黑线.jpg

明白了这一层的原理之后,我们也就有了一个次优级别的解决思路,也即:

用AAPT命令行还原对点9图的预处理

AAPT同时也是一个命令行工具,其在打包过程中参与的多项工作都可以通过命令行来实现。

其中就包括对PNG格式图片的预处理。

于是,具体可操作的步骤也很清晰了:

步骤1:设计组产出源类型的点9图后,即利用AAPT工具转换为已编译类型

这样做还有一个好处就是,AAPT命令行工具会校验源类型点9图的规格,如果不合规就会报错并给出原因提示,这样就可以在生产端时就保证产出点9图的合规性,而不是等到展示的时候才发现有问题。

命令行如下:

 aapt s[ingleCrunch] [-v] -i inputfile -o outputfile

[]表示是可选的完整命令或参数。

步骤2:交付到资源上传平台后,后端改由下发这种已编译类型的点9图

这个过程还需保证不会因流量压缩而将图片转为Webp格式,或者造成“npTc”的辅助数据块丢失。

步骤3:客户端拿到后还需一些额外的处理,以正常识别和展示点9图

这里主要涉及到2个问题:

  1. 我们怎么知道下发的资源是已编译类型的点9图?
  2. 我们怎么告诉系统以点9图的形式正确处理这张图?

这2个问题都可以从Android SDK源码中找到答案。

关于问题1,我们可以从点9图的常见应用场景,即设为视图控件背景的API入手,从View#setBackground方法一路深入直至BitmapFactory#setDensityFromOptions方法,就可以看到:

    private static void setDensityFromOptions(Bitmap outputBitmap, Options opts) {
            ...
            byte[] np = outputBitmap.getNinePatchChunk();
            final boolean isNinePatch = np != null && NinePatch.isNinePatchChunk(np);
           ...
    }

Bitmap#getNinePatchChunk方法返回的是一个byte数组类型的数据,从方法名就可以看出其正是关于点九图规格的辅助块数据:

    public byte[] getNinePatchChunk() {
        return mNinePatchChunk;
    }

NinePatch#isNinePatchChunk方法是一个Native函数,我们等到后面深入点九图Native层结构体时再展开讲:

    public native static boolean isNinePatchChunk(byte[] chunk);

而关于问题2,我们可以通过查找对Bitmap#getNinePatchChunk方法的引用,在Drawable#createFromResourceStream方法中找到一个参考例子:

    public static Drawable createFromResourceStream(@Nullable Resources res,
            @Nullable TypedValue value, @Nullable InputStream is, @Nullable String srcName,
            @Nullable BitmapFactory.Options opts) {
        ...
        Rect pad = new Rect();
        ...
        Bitmap  bm = BitmapFactory.decodeResourceStream(res, value, is, pad, opts);
        if (bm != null) {
            byte[] np = bm.getNinePatchChunk();
            if (np == null || !NinePatch.isNinePatchChunk(np)) {
                np = null;
                pad = null;
            }

            final Rect opticalInsets = new Rect();
            bm.getOpticalInsets(opticalInsets);
            return drawableFromBitmap(res, bm, np, pad, opticalInsets, srcName);
        }
        return null;
    }
    private static Drawable drawableFromBitmap(Resources res, Bitmap bm, byte[] np,
            Rect pad, Rect layoutBounds, String srcName) {

        if (np != null) {
            return new NinePatchDrawable(res, bm, np, pad, layoutBounds, srcName);
        }

        return new BitmapDrawable(res, bm);
    }

可以看到,它是通过在判断NinePatchChunk数据不为空后,构建了一个NinePatchDrawable来告诉系统以点9图的形式正确处理这张图的。

于是我们可以得出结论,客户端要做的额外处理,就是在拿到已编译类型的点9图并构建为Bitmap后:

  1. 先调用Bitmap#getNinePatchChunk方法尝试获取点9图数据

  2. 再通过NinePatch#isNinePatchChunk方法判断是不是点9图数据。

  3. 如果是点9图数据,则利用这个点9图数据构建一个NinePatchDrawable

  4. 如果不是,则构建一个BitmapDrawable。

示例代码如下:

        Glide.with(context).asBitmap().load(url)
            .into(object : CustomTarget(){
                override fun onResourceReady(bitmap: Bitmap, transition: Transition?) {
                    try {
                        val chunk = bitmap.ninePatchChunk
                        val drawable = if (NinePatch.isNinePatchChunk(chunk)) {
                            NinePatchDrawable(context.resources, bitmap, chunk, Rect(), null)
                        } else {
                            BitmapDrawable(context.resources, bitmap);
                        }
                        view.background = drawable;
                    } catch (e: Exception) {
                        e.printStackTrace();
                    }
                }
    
                override fun onLoadCleared(placeholder: Drawable?) {
                }
    
            })

这样就满足了吗?并没有。方案本身虽然可行,但让一向习惯可视化界面操作的设计组同事执行命令行,实在是有点太为难他们了,并且每次产出资源后都要用AAPT工具处理一遍,也确实有点麻烦。

话说回来,命令行工具的底层肯定还是依赖代码来实现的,那有没有可能在客户端侧实现一套与AAPT工具一样的逻辑呢?这就引出了我们一个更次优级别的解决思路,也即:

在客户端侧还原对点9图的预处理

透过上一个方案我们可以了解到,最关键的地方还是那个byte数组类型的点九图数据块(NineChunk),如果我们能知道这个数据块里面实际包含什么内容,就有机会在在客户端侧构造出一份类似的数据。

上一个方案中提到的NinePatch#isNinePatchChunk方法就是我们的突破点。

接下来,就让我们进入Native层查看isNinePatchChunk方法的源码实现吧:

    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;
    }

可以看到,在isNinePatchChunk方法内部实际是将传入的byte数组类型的点9图数据转为一个Res_png_9patch类型的结构体,再通过一个wasDeserialized的结构变量来判断是不是点9图数据的。

这个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.
 * /
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));

很明显,这个结构体就是用来存储点9图规格数据的,我们可以根据该结构体的源码和注释梳理出每个变量的含义:

每个变量的含义.png

根据该结构体注释中的描述,这个结构体是用于指定如何将图像分割成多个部分以进行缩放的,其中:

  • Sx标签标记的是拉伸区域(stretchable),Fx标签标记的是固定区域(fixed)
  • mDivX描述了所有S区域水平方向的起始位置和结束位置
  • mDivY描述了所有S区域垂直方向的起始位置和结束位置
  • mColor描述了每个小区域的颜色

以该结构体注释中的例子来说,mDivX,mDivY,mColor分别如下:

 *      F0   S0    F1     S1
 *   +-----+----+------+-------+
 * S2|  0  |  1 |  2   |   3   |
 *   +-----+----+------+-------+
 *   |     |    |      |       |
 *   |     |    |      |       |
 * F2|  4  |  5 |  6   |   7   |
 *   |     |    |      |       |
 *   |     |    |      |       |
 *   +-----+----+------+-------+
 * S3|  8  |  9 |  10  |   11  |
 *   +-----+----+------+-------+
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]]

我画了一张示意图,应该会更方便理解一点:

注释例子示意图.png

这几个结构体变量所描述的,不正是我们源类型的点9图四周所对应的那些黑色边框的位置吗?

那么,现在我们只需要在Java层定义一个与Res_png_9patch结构体的数据结构一模一样的类,并在填充关键的变量数据后序列化为byte数组类型的数据,就可以作为NinePatchDrawable构造函数的参数了。

怎么做呢?这部分有点复杂,Github上已经有一个大神开源出了方案,可以参考下其源码实现:https://github.com/Anatolii/NinePatchChunk

这里只给出使用层的示例代码:

     Glide.with(context).asBitmap().load(url)
            .into(object : CustomTarget(){
                override fun onResourceReady(bitmap: Bitmap, transition: Transition?) {
                    try {
                        val drawable = NinePatchChunk.create9PatchDrawable(textBackground.context, resource, null)
                        view.background = drawable;
                    } catch (e: Exception) {
                        e.printStackTrace();
                    }
                }

                override fun onLoadCleared(placeholder: Drawable?) {
                }

            })

NinePatchChunk类即为前面说的在Java层定义的类,并提供了几个静态方法用于创建NinePatchDrawable,其在内部会去检测传入的Bitmap实例属于哪种类型:

    public static BitmapType determineBitmapType(Bitmap bitmap) {
        if (bitmap == null) return NULL;
        byte[] ninePatchChunk = bitmap.getNinePatchChunk();
        if (ninePatchChunk != null && android.graphics.NinePatch.isNinePatchChunk(ninePatchChunk))
            return NinePatch;
        if (NinePatchChunk.isRawNinePatchBitmap(bitmap))
            return RawNinePatch;
        return PlainImage;
    }

NinePatch即为已编译类型的点9图,RawNinePatch即为源类型的点9图,源类型是通过PNG图片4个角像素是否为透明且是否包含黑色边框判断的。

    public static boolean isRawNinePatchBitmap(Bitmap bitmap) {
        if (bitmap == null) return false;
        if (bitmap.getWidth() < 3 || bitmap.getHeight() < 3)
            return false;
        if (!isCornerPixelsAreTrasperent(bitmap))
            return false;
        if (!hasNinePatchBorder(bitmap))
            return false;
        return true;
    }

好了,这个就是今天要分享的内容。最后留给大家一个问题,你觉得.9.png的扩展名对于从网络加载点九图有影响吗?

少侠,请留步!若本文对你有所帮助或启发,还请:

  1. 点赞,让更多的人能看到!
  2. 收藏️,好文值得反复品味!
  3. 关注➕,不错过每一次更文!

你的支持是我继续创作的动力,感谢!

参考

  • NinePatch

  • Android动态布局入门及NinePatchChunk解密

  • Android点九图总结以及在聊天气泡中的使用

你可能感兴趣的:(【图像格式篇】可以从网络加载点9图的吗?)