Android 关于点9图在气泡评论里使用的调研

需求是这样的


气泡

1. 点9图简单介绍

Android为了使用同一张图作为大小区域背景,设计了一种可以指定区域拉伸的图片格式“.9.png”,这种图片格式就是点九图。Android 特有的一种格式,在ios开发中,可以在代码中指定某个点进行拉伸。

点9图

如上图,点九图的本质实际上是在图片的四周各增加了1px的像素,并使用纯黑(#FF000000)的线进行标记,其它的与原图没有任何区别
标记位置 含义
左--> 纵向拉伸区域
上--> 横向拉伸区域
右--> 纵向显示区域
下--> 横向显示区域

点9图可以通过ps等p图工具手动画黑线、Draw9patch工具、AndroidStudio、或者
在线工具点击进入

2. 点9图的使用

Android中使用点九图,主要有三种形式,使用res文件夹中的点九图,使用assets文件夹中的点九图以及使用网上拉取的点九图。

注意
Android并不是直接使用点九图,而是在编译时将其转换为另外一种格式,这种格式是将其四周的黑色像素保存至Bitmap类中的一个名为mNinePatchChunk的byte[]中,并抹除掉四周的这一个像素的宽度;接着在使用时,如果Bitmap的这个mNinePatchChunk不为空,且为9patch chunk,则将其构造为NinePatchDrawable,否则将会被构造为BitmapDrawable,最终设置给view,NinePatchDrawable的拉伸主要是通过其draw方法实现的。

总而言之,最后打出的包中的点九图,已经不是原来的带黑线的点九图了。而是通过appt工具,把点9图的黑线信息编码到png的图片字节中。

3. 把点9图应用到气泡中

点9图放到res文件夹, 在编译期已经已经把点9信息合成到png中, 我们使用的时候,系统自动解析信息,所以使用起来基本无感知,但是放到文件和网络中的点9图片不能直接使用。如果直接加载就会出现如下效果

示例图片

黑线仍然存在,且没有固定区域拉伸的效果

4. 解决思路

既然无法直接使用点9图,就要寻求系统是如何使用点9图的,包括他的编译过程,也就是点9信息存储和使用方式。

关于点9图的源码分析,参考腾讯音乐团队的blog,详见文末的参考文献

根据之前的讨论我们知道,画黑线的点九图与普通图片的区别主要在于四周多了1px的黑线,而转换后的点九图则没有这1px的黑线,但是它却包含了用于拉伸的信息。所以我们要从这个信息里面入手。

第一种方案:直接上传点9图片,下载之后再做处理。UI生成端比较方便,但是处理过程比较繁琐, 而且资源图片要和iOS进行区分,后台配置比较繁琐。
第二种方案:设计生成点9图之后,通过appt命令,把点9信息编码到在图片资源中,Android 端获取图片后直接解析点9信息。优点是可以和iOS端共用一个资源图,解析也比较方便,缺点是上传前需要提前进行appt命令合成,而且有合成错误的风险,。
第三种方案:直接和iOS使用同一套资源,Android手动添加patch点,便于适配不使用padding。优点是配置端比较方便,缺点是解析和生成比较繁琐。

第一种方案就不说了,三端都繁琐的事情,就没必要做了。
第二种方案处理流程

a> UI端生成点9图片
b> 通过命令 aapt s -i xx.9.png -o xx.png 生成包含点9信息的图片,上传服务器
c> Android下载之后,解析点9信息

var bmp = Bitmap.createBitmap(bmpTemp)
var chunk = bmp?.ninePatchChunk

d> 判断点9信息正确之后,生成NinePatchDrawable,设置对应view backgroud

if (NinePatch.isNinePatchChunk(chunk)) {
       var ninePatchDrawable =  NinePatchDrawable(context?.resources, bmp, chunk,  NinePatchChunk.getPaddingRect(chunk), null)
       view.post {
            view.background = ninePatchDrawable
        }
} else {
    var bitmapDrawable = BitmapDrawable(context?.resources, bmp)
     view.post {
         view.background = bitmapDrawable
     }
}

中间遇到的一个坑,就是这种方式加载的点9图无法使用padding效果,就是点9图的右下内容区域控制,查资料找到实现方案NinePatchDrawable的第三个参数padding内容, 如下类中的getPaddingRect方法。


import android.graphics.Rect;

import com.richard.base.BaseApplication;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;

/**
 * **************************************
 * 项目名称:Giggle
 *
 * @Author wuzhiguo
 * 创建时间: 2020/8/14     11:25 AM
 * 用途:
 * **************************************
 */
public class NinePatchChunk {

    private static final String TAG = "NinePatchChunk";

    public final Rect mPaddings = new Rect();

    public int mDivX[];
    public int mDivY[];
    public int mColor[];

    private static float density = BaseApplication.Companion.getInstance().getResources().getDisplayMetrics().density;

    private static void readIntArray(final int[] data, final ByteBuffer buffer) {
        for (int i = 0, n = data.length; i < n; ++i)
            data[i] = buffer.getInt();
    }

    private static void checkDivCount(final int length) {
        if (length == 0 || (length & 0x01) != 0)
            throw new IllegalStateException("invalid nine-patch: " + length);
    }

    public static Rect getPaddingRect(final byte[] data) {
        NinePatchChunk deserialize = deserialize(data);
        if (deserialize == null) {
            return new Rect();
        }
        return deserialize.mPaddings;
    }

    public static NinePatchChunk deserialize(final byte[] data) {
        final ByteBuffer byteBuffer =
                ByteBuffer.wrap(data).order(ByteOrder.nativeOrder());

        if (byteBuffer.get() == 0) {
            return null; // is not serialized
        }

        final NinePatchChunk chunk = new NinePatchChunk();
        chunk.mDivX = new int[byteBuffer.get()];
        chunk.mDivY = new int[byteBuffer.get()];
        chunk.mColor = new int[byteBuffer.get()];

        try {
            checkDivCount(chunk.mDivX.length);
            checkDivCount(chunk.mDivY.length);
        } catch (Exception e) {
            return null;
        }


        // skip 8 bytes
        byteBuffer.getInt();
        byteBuffer.getInt();


        chunk.mPaddings.left = byteBuffer.getInt();
        chunk.mPaddings.right = byteBuffer.getInt();
        chunk.mPaddings.top = byteBuffer.getInt();
        chunk.mPaddings.bottom = byteBuffer.getInt();


        // skip 4 bytes
        byteBuffer.getInt();

        readIntArray(chunk.mDivX, byteBuffer);
        readIntArray(chunk.mDivY, byteBuffer);
        readIntArray(chunk.mColor, byteBuffer);

        return chunk;
    }
}

另一个坑就是fresco图片加载框架通过ImagePipeline、BaseBitmapDataSubscriber获取的bitmap对象中,NinePatchChunk信息是空的,只能通过DataSource方式获取字节流转换成bitmap才可以。

第三种方案,手动添加拉伸信息到bitmap

要实现这种方案,首先要对Bitmap和NinePatch以及png的tunk信息有一定的了解,详情可参阅QQ音乐的blog讲解

实现方案如下


import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.NinePatch
import android.graphics.drawable.NinePatchDrawable
import java.nio.ByteBuffer
import java.nio.ByteOrder

/**
 * **************************************
 * 项目名称:Giggle
 *
 * @Author wuzhiguo
 * 创建时间: 2020/8/14     11:25 AM
 * 用途: 手动构建NinePatch
 * **************************************
 */
class NinePatchBuilder {
    var width: Int
    var height: Int
    var bitmap: Bitmap? = null
    var resources: Resources? = null
    private val xRegions = mutableListOf<Int>()
    private val yRegions = mutableListOf<Int>()

    constructor(resources: Resources?, bitmap: Bitmap) {
        width = bitmap.width
        height = bitmap.height
        this.bitmap = bitmap
        this.resources = resources
    }

    constructor(width: Int, height: Int) {
        this.width = width
        this.height = height
    }

    fun addXRegion(x: Int, width: Int): NinePatchBuilder {
        xRegions.add(x)
        xRegions.add(x + width)
        return this
    }

    fun addXRegionPoints(x1: Int, x2: Int): NinePatchBuilder {
        xRegions.add(x1)
        xRegions.add(x2)
        return this
    }

    fun addXRegion(xPercent: Float, widthPercent: Float): NinePatchBuilder {
        val xtmp = (xPercent * width).toInt()
        xRegions.add(xtmp)
        xRegions.add(xtmp + (widthPercent * width).toInt())
        return this
    }

    fun addXRegionPoints(x1Percent: Float, x2Percent: Float): NinePatchBuilder {
        xRegions.add((x1Percent * width).toInt())
        xRegions.add((x2Percent * width).toInt())
        return this
    }

    fun addXCenteredRegion(width: Int): NinePatchBuilder {
        val x = ((this.width - width) / 2)
        xRegions.add(x)
        xRegions.add(x + width)
        return this
    }

    fun addXCenteredRegion(widthPercent: Float): NinePatchBuilder {
        val width = (widthPercent * width).toInt()
        val x = ((this.width - width) / 2)
        xRegions.add(x)
        xRegions.add(x + width)
        return this
    }

    fun addYRegion(y: Int, height: Int): NinePatchBuilder {
        yRegions.add(y)
        yRegions.add(y + height)
        return this
    }

    fun addYRegionPoints(y1: Int, y2: Int): NinePatchBuilder {
        yRegions.add(y1)
        yRegions.add(y2)
        return this
    }

    fun addYRegion(yPercent: Float, heightPercent: Float): NinePatchBuilder {
        val ytmp = (yPercent * height).toInt()
        yRegions.add(ytmp)
        yRegions.add(ytmp + (heightPercent * height).toInt())
        return this
    }

    fun addYRegionPoints(y1Percent: Float, y2Percent: Float): NinePatchBuilder {
        yRegions.add((y1Percent * height).toInt())
        yRegions.add((y2Percent * height).toInt())
        return this
    }

    fun addYCenteredRegion(height: Int): NinePatchBuilder {
        val y = ((this.height - height) / 2)
        yRegions.add(y)
        yRegions.add(y + height)
        return this
    }

    fun addYCenteredRegion(heightPercent: Float): NinePatchBuilder {
        val height = (heightPercent * height).toInt()
        val y = ((this.height - height) / 2)
        yRegions.add(y)
        yRegions.add(y + height)
        return this
    }

    fun buildChunk(): ByteArray {
        if (xRegions.size == 0) {
            xRegions.add(0)
            xRegions.add(width)
        }
        if (yRegions.size == 0) {
            yRegions.add(0)
            yRegions.add(height)
        }
        /* example code from a anwser above
        // The 9 patch segment is not a solid color.
        private static final int NO_COLOR = 0x00000001;
        ByteBuffer buffer = ByteBuffer.allocate(56).order(ByteOrder.nativeOrder());
        //was translated
        buffer.put((byte)0x01);
        //divx size
        buffer.put((byte)0x02);
        //divy size
        buffer.put((byte)0x02);
        //color size
        buffer.put(( byte)0x02);

        //skip
        buffer.putInt(0);
        buffer.putInt(0);

        //padding
        buffer.putInt(0);
        buffer.putInt(0);
        buffer.putInt(0);
        buffer.putInt(0);

        //skip 4 bytes
        buffer.putInt(0);

        buffer.putInt(left);
        buffer.putInt(right);
        buffer.putInt(top);
        buffer.putInt(bottom);
        buffer.putInt(NO_COLOR);
        buffer.putInt(NO_COLOR);

        return buffer;*/
        val NO_COLOR = 1 //0x00000001;
        val COLOR_SIZE = 9 //could change, may be 2 or 6 or 15 - but has no effect on output
        val arraySize: Int = 1 + 2 + 4 + 1 + xRegions.size + yRegions.size + COLOR_SIZE
        val byteBuffer: ByteBuffer =
            ByteBuffer.allocate(arraySize * 4).order(ByteOrder.nativeOrder())
        byteBuffer.put(1.toByte()) //was translated
        byteBuffer.put(xRegions.size.toByte()) //divisions x
        byteBuffer.put(yRegions.size.toByte()) //divisions y
        byteBuffer.put(COLOR_SIZE.toByte()) //color size

        //skip
        byteBuffer.putInt(0)
        byteBuffer.putInt(0)

        //padding -- always 0 -- left right top bottom
        byteBuffer.putInt(0)
        byteBuffer.putInt(0)
        byteBuffer.putInt(0)
        byteBuffer.putInt(0)

        //skip
        byteBuffer.putInt(0)
        for (rx in xRegions) byteBuffer.putInt(rx) // regions left right left right ...
        for (ry in yRegions) byteBuffer.putInt(ry) // regions top bottom top bottom ...
        for (i in 0 until COLOR_SIZE) byteBuffer.putInt(NO_COLOR)
        return byteBuffer.array()
    }

    fun buildNinePatch(): NinePatch? {
        val chunk = buildChunk()
        return if (bitmap != null) NinePatch(bitmap, chunk, null) else null
    }

    fun build(): NinePatchDrawable? {
        val ninePatch = buildNinePatch()
        return ninePatch?.let { NinePatchDrawable(resources, it) }
    }
}

简单使用方式如下

val builder = NinePatchBuilder(resources, bmpTemp)
builder.addXCenteredRegion(10)
builder.addYCenteredRegion(10) 
val drawable = builder.build()
view.background = drawable

这种方式会保存原来的点9图padding信息,如果原图没有的话, 可以手动在view上做padding控制。

5. 注意事项和遇见的坑

  1. 一定要使用缓存,不然异步加载的过程中,在list中显示会有问题,跳变很严重,尤其是在快速滑动的时候, 最好能复用图片缓存框架,内存+磁盘两种缓存都要做。

  2. 代码操作bitmap一定要及时释放回收,最好能有一定的保障机制,避免bitmap大量占用内存。比如发现图片过大,可以走default处理方式。

  3. 给view设置Drawable背景的时候,会把view本身的padding删除,解决方案是提前把view的padding获取到,设置完drawable背景之后,再把padding设置上

public static void setBackgroundAndKeepPadding(View view, Drawable backgroundDrawable) {
    Rect drawablePadding = new Rect();
    backgroundDrawable.getPadding(drawablePadding);
    int top = view.getPaddingTop() + drawablePadding.top;
    int left = view.getPaddingLeft() + drawablePadding.left;
    int right = view.getPaddingRight() + drawablePadding.right;
    int bottom = view.getPaddingBottom() + drawablePadding.bottom;

    view.setBackgroundDrawable(backgroundDrawable);
    view.setPadding(left, top, right, bottom);
}
  1. 屏幕适配问题,网络下发的图可能尺寸对不上,要先适配屏幕,再做点9,再设置背景
/**
    * 指定大小缩放, 为了屏幕适配
    * @param bmpTemp Bitmap
    * @param bubblePicHeight Float
    * @return Bitmap
    */
   private fun decodeBitmap(bmpTemp: Bitmap, bubblePicHeight: Float): Bitmap {
       val width = bmpTemp.width
       val height = bmpTemp.height
       //计算压缩的比率
       val scaleHeight = bubblePicHeight / height
       //获取想要缩放的matrix
       val matrix = Matrix()
       matrix.postScale(scaleHeight, scaleHeight)
       //获取新的bitmap
       return Bitmap.createBitmap(bmpTemp, 0, 0, width, height, matrix, false)
   }
  1. View设置Drawable背景,再去掉背景之后,原来的padding并不会去掉,导致控件无法还原,所以要动态设置padding

  2. 更新列表数据闪动问题, 因为设置drawable背景比较耗时,且有可能设置不同padding的drawable,所以会导致列表view闪动, 解决方案是局部更新item,或者更新到具体的view

具体方案二和方案三哪种方案更合适,预研阶段进行了测试,看不出来有什么性能的差异,后续实际开发会持续进行测试验证。

参考文章
QQ音乐- Android点九图总结以及在聊天气泡中的使用
stackoverflow 社区

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