我正在参与「兔了个兔」创意投稿大赛,概况请看:「兔了个兔」创意投稿大赛

前情概要

春节又到了,作为一款丰厚的交际类运用,免不了要上线几款和新年主题相关的谈天气泡布景。这不,心爱的兔兔和财神爷等等都安排上了,可是Android的气泡图上线流程等我了解后着实感觉有少许复杂,比照隔壁的iOS真是被吊打,且听我自始至终细细详解一遍。

创立.9.png格局的图片

聊天气泡图片的动态拉伸、镜像与适配
在开发上图所示的功用中,咱们一般都会运用 .9.png 图片,那么一张一般png格局的图片怎样处理成 .9.png 格局呢,一起来简略回忆下。

在Android Studio中,对一张一般png图片右键,然后点击 “Create 9-Patch file…”,选择新图片保存的方位后,双击新图就会显现图片编辑器,图片左边的黑色线段能够操控图片的竖向拉伸区域,上侧的黑色线段能够操控图片的横向拉伸区域,下侧和右侧的黑色线段则能够操控内容的填充区域,编辑后如下图所示:

聊天气泡图片的动态拉伸、镜像与适配

上图呢是居中拉伸的状况,但是假如中心有不可拉伸元素的话如何处理呢(一般状况下咱们也不会有这样的谈天气泡,这儿是托付UI小姐姐专门修正图片做的示例),如下图所示,这时候拉伸的话左边和上侧就需求运用两条(多条)线段来操控拉伸的区域了,然后防止中心的财神爷被拉伸:

聊天气泡图片的动态拉伸、镜像与适配

OK,.9.png格局图片的处理便是这样了。

从资源文件夹加载.9.png图片

比如加载drawable或许mipmap资源文件夹中的图片,这种加载办法的话很简略,直接给文字设置布景就能够了,刚刚处理过的小兔子图片放在drawable-xxhdpi文件夹下,命名为rabbit.9.png,示例代码如下所示:

textView.background = ContextCompat.getDrawable(this, R.drawable.rabbit)

从本地文件加载“.9.png”图片

假如咱们将上述rabbit.9.png图片直接放到运用缓存文件夹中,然后经过bitmap进行加载,伪代码如下:

textView.text = "直接加载本地.9.png图片"
textView.background =
            BitmapDrawable.createFromPath(cacheDir.absolutePath + File.separator + "rabbit.9.png")

则显现作用如下:

聊天气泡图片的动态拉伸、镜像与适配

能够看到,这样是达不到咱们想要的作用的,整张图片被直接进行拉伸了,完全没有咱们上文规划的拉伸作用。

其实要想到达上文规划的居中拉伸作用,咱们需求运用aapt东西对.9.png图片再进行下处理(在Windows系统上aapt东西所在方位为:你SDK目录\build-tools\版本号\aapt.exe),Windows下的指令如下所示:

.\aapt.exe s -i .\rabbit.9.png -o rabbit9.png

将处理往后新生成的rabbit9.png图片放入到运用缓存文件夹中,然后经过bitmap直接进行加载,代码如下:

textView.text = "加载经aapt处理过的本地图片"
textView.background =
            BitmapDrawable.createFromPath(cacheDir.absolutePath + File.separator + "rabbit9.png")

则显现作用正常,如下所示:

聊天气泡图片的动态拉伸、镜像与适配
也便是说假如咱们需求从本地或许assets文件夹中加载可拉伸图片的话,那么整个处理的流程便是:根据源rabit.png图片创立rabbit.9.png图片 -> 运用aapt处理生成新的rabbit9.png图片。

项目痛点

所以,以上便是目前项目中的痛点,每次增加一个谈天气泡布景,Android组都需求从UI小姐姐那里拿两张图片,一左一右,然后别离处理成 .9.png 图,然后还需求用aapt东西处理,然后再上传到服务器。后台还需求针对Android和iOS平台下发不同的图片,这也太复杂了。 所以咱们的方针便是只需求一张通用的气泡布景图,直接上传服务器,移动端下载下来后,在本地做 拉伸、镜像、缩放等 功用的处理,那么一起来探究下吧。

进阶探究

咱们来先比照看下iOS的处理办法,然后升级咱们的项目。

iOS中的办法

只需求一个原始的png的图片即可,人家有专门的resizableImage函数来处理拉伸,大致的示例代码如下所示:

let image : UIImage = UIImage(named: "rabbit.png")
image.resizableImage(withCapInsets: .init(top: 20, left: 20, right:20, bottom:20))

留意:这儿的withCapInsets参数的含义应该是等同与Android中的padding。padding的区域便是被维护不会拉伸的区域,而剩余的区域则会被拉伸来填充。 能够看到这儿其实是有必定的束缚标准的,UI小姐姐是按照此标准来进行气泡图的规划的,所以咱们也能够遵从大致的束缚,和iOS运用同一张气泡布景图片即可。

Android中的探究

那么在Android中有没有或许也直接经过代码来处理图片的拉伸呢?也能够有!!!

原理请参阅《Android动态布局入门及NinePatchChunk解密》,各种思维的碰撞请参阅《Create a NinePatch/NinePatchDrawable in runtime》。

站在前面巨人的膀子上看,最终咱们需求自界说创立的便是一个NinePatchDrawable目标,这样能够直接设置给TextView的background特点或许其他drawable特点。那么先来看下创立该目标所需的参数吧:

/**
* Create drawable from raw nine-patch data, setting initial target density
* based on the display metrics of the resources.
*/
public NinePatchDrawable(
    Resources res,
    Bitmap bitmap,
    byte[] chunk,
    Rect padding,
    String srcName
)

主要便是其中的两个参数:

  • byte[] chunk:结构chunk数据,是结构可拉伸图片的数据结构
  • Rect padding:padding数据,同xml中的padding含义,不要被Rect所迷惑

结构chunk数据

这儿结构数据可是有说法的,咱们先以上文兔子图片的拉伸做示例,在该示例中,横向和竖向都别离有一条线段来操控拉伸,那么咱们界说如下: 横向线段的起点方位的百分比为patchHorizontalStart,结尾方位的百分比为patchHorizontalEnd; 竖向线段的起点方位的百分比为patchVerticalStart,结尾方位的百分比为patchVerticalEnd; width和height别离为传入进来的bitmap的宽度和高度,示例代码如下:

private fun buildChunk(): ByteArray {
    // 横向和竖向都只要一条线段,一条线段有两个端点
    val horizontalEndpointsSize = 2
    val verticalEndpointsSize = 2
    val NO_COLOR = 0x00000001
    val COLOR_SIZE = 9 //could change, may be 2 or 6 or 15 - but has no effect on output
    val arraySize = 1 + 2 + 4 + 1 + horizontalEndpointsSize + verticalEndpointsSize + COLOR_SIZE
    val byteBuffer = ByteBuffer.allocate(arraySize * 4).order(ByteOrder.nativeOrder())
    byteBuffer.put(1.toByte()) //was translated
    byteBuffer.put(horizontalEndpointsSize.toByte()) //divisions x
    byteBuffer.put(verticalEndpointsSize.toByte()) //divisions y
    byteBuffer.put(COLOR_SIZE.toByte()) //color size
    // skip
    byteBuffer.putInt(0)
    byteBuffer.putInt(0)
    // padding 设为0,即便设置了数据,padding依旧或许不收效
    byteBuffer.putInt(0)
    byteBuffer.putInt(0)
    byteBuffer.putInt(0)
    byteBuffer.putInt(0)
    // skip
    byteBuffer.putInt(0)
    // regions 操控横向拉伸的线段数据
    val patchLeft = (width * patchHorizontalStart).toInt()
    val patchRight = (width * patchHorizontalEnd).toInt()
    byteBuffer.putInt(patchLeft)
    byteBuffer.putInt(patchRight)
    // regions 操控竖向拉伸的线段数据
    val patchTop = (height * patchVerticalStart).toInt()
    val patchBottom = (height * patchVerticalEnd).toInt()
    byteBuffer.putInt(patchTop)
    byteBuffer.putInt(patchBottom)
    for (i in 0 until COLOR_SIZE) {
        byteBuffer.putInt(NO_COLOR)
    }
    return byteBuffer.array()
}

OK,上面是横向竖向都有一条线段来操控图片拉伸的状况,再看上文财神爷图片的拉伸示例,就别离都是两条线段操控了,也有或许需求更多条线段来操控,所以咱们需求略微改造下咱们的代码,首先界说一个PatchRegionBean的实体类,该类界说了一条线段的起点和结尾(都是百分比):

data class PatchRegionBean(
    val start: Float,
    val end: Float
)

在类中界说横向和竖向竖向线段的列表,用来存储这些数据,然后改造buildChunk()办法如下:

private var patchRegionHorizontal = mutableListOf<PatchRegionBean>()
private var patchRegionVertical = mutableListOf<PatchRegionBean>()
private fun buildChunk(): ByteArray {
    // 横向和竖向端点的数量 = 线段数量 * 2
    val horizontalEndpointsSize = patchRegionHorizontal.size * 2
    val verticalEndpointsSize = patchRegionVertical.size * 2
    val NO_COLOR = 0x00000001
    val COLOR_SIZE = 9 //could change, may be 2 or 6 or 15 - but has no effect on output
    val arraySize = 1 + 2 + 4 + 1 + horizontalEndpointsSize + verticalEndpointsSize + COLOR_SIZE
    val byteBuffer = ByteBuffer.allocate(arraySize * 4).order(ByteOrder.nativeOrder())
    byteBuffer.put(1.toByte()) //was translated
    byteBuffer.put(horizontalEndpointsSize.toByte()) //divisions x
    byteBuffer.put(verticalEndpointsSize.toByte()) //divisions y
    byteBuffer.put(COLOR_SIZE.toByte()) //color size
    // skip
    byteBuffer.putInt(0)
    byteBuffer.putInt(0)
    // padding 设为0,即便设置了数据,padding依旧或许不收效
    byteBuffer.putInt(0)
    byteBuffer.putInt(0)
    byteBuffer.putInt(0)
    byteBuffer.putInt(0)
    // skip
    byteBuffer.putInt(0)
    // regions 操控横向拉伸的线段数据
    patchRegionHorizontal.forEach {
        byteBuffer.putInt((width * it.start).toInt())
        byteBuffer.putInt((width * it.end).toInt())
    }
    // regions 操控竖向拉伸的线段数据
    patchRegionVertical.forEach {
        byteBuffer.putInt((height * it.start).toInt())
        byteBuffer.putInt((height * it.end).toInt())
    }
    for (i in 0 until COLOR_SIZE) {
        byteBuffer.putInt(NO_COLOR)
    }
    return byteBuffer.array()
}

结构padding数据

比照刚刚的chunk数据,padding就显得尤其简略了,留意这儿传递来的值依旧是百分比,而且需求留意别和Rect的含义搞混了即可:

fun setPadding(
    paddingLeft: Float,
    paddingRight: Float,
    paddingTop: Float,
    paddingBottom: Float,
): NinePatchDrawableBuilder {
    this.paddingLeft = paddingLeft
    this.paddingRight = paddingRight
    this.paddingTop = paddingTop
    this.paddingBottom = paddingBottom
    return this
}
/**
 * 操控内容填充的区域
 * (留意:这儿的left,top,right,bottom同xml文件中的padding意思一致,只不过这儿是百分比形式)
 */
private fun buildPadding(): Rect {
    val rect = Rect()
    rect.left = (width * paddingLeft).toInt()
    rect.right = (width * paddingRight).toInt()
    rect.top = (height * paddingTop).toInt()
    rect.bottom = (height * paddingBottom).toInt()
    return rect
}

镜像翻转功用

由于是谈天气泡布景,所以一般都会有左右两个方位的展现,而这俩文件一般状况下都是横向镜像显现的,在Android中如同也没有直接的图片镜像功用,但好在之前做海外项目LTR以及RTL时候了解到一个投机取巧的办法,经过设置scale特点为-1来实现。这儿咱们相同能够这么做,由于最终处理的都是bitmap图片,示例代码如下:

/**
 * 结构bitmap信息
 * 留意:需求判别是否需求做横向的镜像处理
 */
private fun buildBitmap(): Bitmap? {
    return if (!horizontalMirror) {
        bitmap
    } else {
        bitmap?.let {
            val matrix = Matrix()
            matrix.setScale(-1f, 1f)
            val newBitmap = Bitmap.createBitmap(
                it,
                0, 0, it.width, it.height,
                matrix, true
            )
            it.recycle()
            newBitmap
        }
    }
}

假如需求镜像处理咱们就经过设置Matrix的scaleX的特点为-1f,这就能够做到横向镜像的作用,竖向则坚持不变,然后经过Bitmap类创立新的bitmap即可。 图像镜像回转的状况下,还需求留意的两点是:

  • chunk的数据中横向内容需求重新处理
  • padding的数据中横向内容需求重新处理
/**
 * chunk数据的修正
 */
if (horizontalMirror) {
    patchRegionHorizontal.forEach {
        byteBuffer.putInt((width * (1f - it.end)).toInt())
        byteBuffer.putInt((width * (1f - it.start)).toInt())
    }
} else {
    patchRegionHorizontal.forEach {
        byteBuffer.putInt((width * it.start).toInt())
        byteBuffer.putInt((width * it.end).toInt())
    }
}
/**
 * padding数据的修正
 */
if (horizontalMirror) {
    rect.left = (width * paddingRight).toInt()
    rect.right = (width * paddingLeft).toInt()
} else {
    rect.left = (width * paddingLeft).toInt()
    rect.right = (width * paddingRight).toInt()
}

屏幕的适配

屏幕适配的话其实便是利用Bitmap的density特点,假如UI给定的图是按照480dpi规划的,那么就设置为480dpi或许附近的dpi即可:

// 留意:是densityDpi的值,320、480、640等
bitmap.density = 480

简略封装

经过上述两步重要的进程咱们已经知道如何结构所需的chunk和padding数据了,那么简略封装一个类来处理吧,加载的图片咱们能够经过资源文件夹(drawable、mipmap),asstes文件夹,手机本地文件夹来获取,所以对上述三种类型都做下支撑:

/**
 * 设置资源文件夹中的图片
 */
fun setResourceData(
    resources: Resources,
    resId: Int,
    horizontalMirror: Boolean = false
): NinePatchDrawableBuilder {
    val bitmap: Bitmap? = try {
        BitmapFactory.decodeResource(resources, resId)
    } catch (e: Throwable) {
        e.printStackTrace()
        null
    }
    return setBitmapData(
        bitmap = bitmap,
        resources = resources,
        horizontalMirror = horizontalMirror
    )
}
/**
 * 设置本地文件夹中的图片
 */
fun setFileData(
    resources: Resources,
    file: File,
    horizontalMirror: Boolean = false
): NinePatchDrawableBuilder {
    val bitmap: Bitmap? = try {
        BitmapFactory.decodeFile(file.absolutePath)
    } catch (e: Throwable) {
        e.printStackTrace()
        null
    }
    return setBitmapData(
        bitmap = bitmap,
        resources = resources,
        horizontalMirror = horizontalMirror
    )
}
/**
 * 设置assets文件夹中的图片
 */
fun setAssetsData(
    resources: Resources,
    assetFilePath: String,
    horizontalMirror: Boolean = false
): NinePatchDrawableBuilder {
    var bitmap: Bitmap?
    try {
        val inputStream = resources.assets.open(assetFilePath)
        bitmap = BitmapFactory.decodeStream(inputStream)
        inputStream.close()
    } catch (e: Throwable) {
        e.printStackTrace()
        bitmap = null
    }
    return setBitmapData(
        bitmap = bitmap,
        resources = resources,
        horizontalMirror = horizontalMirror
    )
}
/**
 * 直接处理bitmap数据
 */
fun setBitmapData(
    bitmap: Bitmap?,
    resources: Resources,
    horizontalMirror: Boolean = false
): NinePatchDrawableBuilder {
    this.bitmap = bitmap
    this.width = bitmap?.width ?: 0
    this.height = bitmap?.height ?: 0
    this.resources = resources
    this.horizontalMirror = horizontalMirror
    return this
}

横向和竖向的线段需求支撑多段,所以别离运用两个列表来进行管理:

fun setPatchHorizontal(vararg patchRegion: PatchRegionBean): NinePatchDrawableBuilder {
    patchRegion.forEach {
        patchRegionHorizontal.add(it)
    }
    return this
}
fun setPatchVertical(vararg patchRegion: PatchRegionBean): NinePatchDrawableBuilder {
    patchRegion.forEach {
        patchRegionVertical.add(it)
    }
    return this
}

演示示例

咱们运用一个5×5的25宫格图片来进行演示,这样咱们能够很便利的看出来拉伸或许边距的设置究竟有没有收效,将该图片放入资源文件夹中,页面上创立一个展现该图片用的ImageView,假设图片大小是200×200,然后创立一个TextView,经过咱们自己的可拉伸功用设置文字的布景。

(注:演示所用的图片是请UI小哥哥帮助处理的,听完说完我的需求后,UI小哥哥二话没说当着我的面直接出了十来种颜色风格的图片让我选,适当给力!!!)

一条线段操控的拉伸

示例代码如下:

textView.width = 800
textView.background = NinePatchDrawableBuilder()
            .setResourceData(
                resources = resources,
                resId = R.drawable.sample_1,
                horizontalMirror = false
            )
            .setPatchHorizontal(
                PatchRegionBean(start = 0.4f, end = 0.6f),
            )
            .build()

显现作用如下:

聊天气泡图片的动态拉伸、镜像与适配
能够看到竖向上没有拉伸,横向上图片 0.4-0.6 的区域全部被拉伸,然后填充了800的宽度。

两条线段操控的拉伸

接下来再看这段代码示例,这儿咱们横向上添加了两条线段,别离是从0.2-0.4,0.6-0.8:

textView.width = 800
textView.background = NinePatchDrawableBuilder()
            .setResourceData(
                resources = resources,
                resId = R.drawable.sample_1,
                horizontalMirror = false
            )
            .setPatchHorizontal(
                PatchRegionBean(start = 0.2f, end = 0.4f),
                PatchRegionBean(start = 0.6f, end = 0.8f),
            )
            .build()

显现作用如下:

聊天气泡图片的动态拉伸、镜像与适配
能够看到横向上中心的(0.4-0.6)的部分没有被拉伸,(0.2-0.4)以及(0.6-0.8)的部分被别离拉伸,然后填充了800的宽度。

padding的示例

咱们添加上文字,而且结合padding来进行演示下,这儿先设置padding距离鸿沟都为0.2的百分比,示例代码如下:

textView.background = NinePatchDrawableBuilder()
            .setResourceData(
                resources = resources,
                resId = R.drawable.sample_2,
                horizontalMirror = false
            )
            .setPatchHorizontal(
                PatchRegionBean(start = 0.4f, end = 0.6f),
            )
            .setPatchVertical(
                PatchRegionBean(start = 0.4f, end = 0.6f),
            )
            .setPadding(
                paddingLeft = 0.2f,
                paddingRight = 0.2f,
                paddingTop = 0.2f,
                paddingBottom = 0.2f
            )
            .build()

显现作用如下:

聊天气泡图片的动态拉伸、镜像与适配

然后将padding的边距都改为0.4的百分比,显现作用如下:

聊天气泡图片的动态拉伸、镜像与适配

屏幕适配的示例

上述的图片都是在480dpi下显现的,这儿咱们将densityDpi设置为960,按道理来说拉伸图展现会小一倍,如下图所示:

textView.background = NinePatchDrawableBuilder()
            ......
            .setDensityDpi(densityDpi = 960)
            .build()

聊天气泡图片的动态拉伸、镜像与适配

作用一览

整个东西类实现完毕后,又简略写了两个页面经过设置各种参数来实时预览图片拉伸和镜像以及padding的状况,作用展现如下:

聊天气泡图片的动态拉伸、镜像与适配

整体的探究进程到此基本就结束了,作用是实现了,然而功能和兼容性还无法确保,接下来需求进一步做下测验才干上线。或许有大佬很早就接触过这些功用,假如能指点下,不才则不胜感激。

文中若有纰漏之处还请我们多多指教。

参阅文章

  1. Android 点九图机制讲解及在谈天气泡中的运用
  2. Android动态布局入门及NinePatchChunk解密
  3. Android点九图总结以及在谈天气泡中的运用