【图像格式篇】可以从网络加载点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<Bitmap>(){
                override fun onResourceReady(bitmap: Bitmap, transition: Transition<in Bitmap>?) {
                    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<const Res_png_9patch*>(array);
            int8_t wasDeserialized = chunk->wasDeserialized;
            env->ReleaseByteArrayElements(obj, const_cast<jbyte*>(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<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);
    }
} __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<Bitmap>(){
                override fun onResourceReady(bitmap: Bitmap, transition: Transition<in Bitmap>?) {
                    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. 关注➕,不错过每一次更文!

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

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容