普通视图

发现新文章,点击刷新页面。
昨天以前u3blog

一张启动图引发的思考--探索.9图原理和应用场景

2022年10月8日 15:02

highlight: vs

theme: juejin

引子

小u啊,我们应用启动的时候有一段白屏,不雅观,你给整个启动图上去,给,这里是资源图片

换好了

嗯,不错不错,咦,这个小米fold怎么显示了两个logo?

啊?这。。。我来看看

是这样的,activity启动图和启动背景图标一起显示了,但是启动图片又不适配fold这种狭长的屏幕,而且下半部分由于没有背景,是透明的,所以就显示了两个图标,一个启动图标,一个启动图图片图标,请看示意图

示意图.png

那怎么解决呢?

有几个办法,1.让启动图拉伸,覆盖下部分区域,但是会有形变,不够优雅,2.给imageview设置一个白色背景,让它不透明,3.使用android studio的。9图片制作工具,让空白部分拉伸,内容不拉伸,非常优雅。

图片.png

好,那你先试试.9图片吧

.9简介

我知道看这篇文章的朋友肯定对.9图或多或少有点了解,就简单介绍下就行
.9图可以通过设置来让图片某一部分拉伸 其他部分不拉伸 还可以控制图片里面显示内容的区域
这就是.9图的简介 够简了吧 如果想看更详细的说明可以移步谷歌官方的说明 大体上也是这意思

这个是官方的文档https://developer.android.com/studio/write/draw9patch?hl=zh-cn

图片.png

图片.png

图片.png

这是.9图在三种拉伸情况下的例子,纵向,横向,横纵双向。

.9图制作

网上很多文章说最简单是用as内置工具来做 但是网上文章好多都是很早的 都是老版本工具 让我们来看看这工具现在啥样

图片.png
好像也不是很简单啊 这些东西做什么用的第一次看确实会很蒙,这里介绍下这个工具

  • Zoom:调整图形在绘制区域中的缩放级别。

  • Patch scale:调整图像在预览区域中的比例。

  • Show lock:当鼠标悬停在图形的不可绘制区域上时以直观方式呈现。

  • Show patches:预览绘制区域中的可拉伸图块(粉色为可拉伸图块),如上面的图 2 所示。

  • Show content:突出显示预览图像中的内容区域(紫色为允许绘制内容的区域),如图 2 所示。

  • Show bad patches:在拉伸时可能会在图形中产生伪影的图块区域周围添加红色边框,如图 2 所示。如果您消除所有不良图块,已拉伸图像的视觉连贯性将得以保持。
    怎么生成.9图不是重点,网上文章不要太多,最主要就是绘制左边和上面的黑线,左边黑线控制的是哪里可以被上下拉伸,上面黑线控制哪里可以被水平拉伸,可以有多条,右边和下面的黑线控制了哪里可以放内容,不在这个区域的内容不会被显示。

当然 还有很多办法生成.9图 单独工具或者在线工具都行 看个人喜好了。

.9图原理

上面的介绍都很大众化 那么为啥.9图这么神奇呢?它是什么原理呢,这个好像没什么人说过,这里也简单阐述下。

主要是四条黑线 分为两组
左边和上面的黑线 负责判定图片哪个部分可以被拉伸
右边和下面的黑线 负责确定图片内部展示内容的区域 比如这个图是个聊天气泡 内容是一堆文字

大概就是下面这个图的样子 分成了九个区域

图片.png

我们把他们编号成1-9,这几个区域对应情况如下

  1. 1 3 7 9 号区域,不在两条黑线区域内,不会被拉伸
  2. 4 6 号区域,只在左侧黑线范围内,可以被上下拉伸
  3. 2 8 号区域,只在上面黑线区域内,会被左右拉伸
  4. 5号区域,同时在上和左区域内,会被上下左右拉伸

回到刚才的问题 为啥处理之后就能控制拉伸和内容了?

首先我们发现处理之后的图片后缀还是原来的 证明没有变成其他格式 但是名字里加上了.9

那猜测是不是在文件里加上了一些额外信息 用.9作为标识 图片系统处理拉伸的时候就去读这些信息

那么 到底加了什么信息呢 又是怎么判断和使用的呢 下面一一道来

首先 我们给图片加了什么信息

我们看看官方怎么描述.9图的

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

可以看到,.9图本质上还是png图片,但是加了1像素边框,且名字里加了个.9。

让我们来看看什么是png图片,以及它的数据构成

The PNG format provides a portable, legally unencumbered, well-compressed, well-specified standard for lossless bitmapped image files.

A PNG file consists of a PNG signature followed by a series of chunks. This chapter defines the signature and the basic properties of chunks.

png的签名块后面跟了两个数据块critical chunk和ancillary chunks,其中critical chunk包含关键数据,也是每个图片必须有的,ancillary chunks包含一些辅助信息,png如果不识别这些辅助块,可以忽略它打到向下兼容的目的。

签名块就是一个八个字节的十六进制码,用来标识图片。

重点来看看数据块的布局
名称 | 字节数 | 说明 |
| ———————– | —- | ——————————— |
| 长度(Length) | 4字节 | 指定数据块中数据区域的长度,长度不可超过(231-1)个字节 |
| 数据块类型码(Chunk Type Code) | 4字节 | 数据块类型码由ASCII字母(A-Z和a-z)组成的”数据块符号” |
| 数据块数据(Chunk Data) | 可变长度 | 存储数据块类型码指定的数据 |
| 循环冗余检测(CRC) | 4字节 | 存储用来检测是否文件传输有误的循环冗余码

看到这个可以猜测,我们添加的黑色边框就是往辅助块里面加了内容,在展示的时候识别添加的信息,达到控制哪些地方伸缩的目的。

让我们看看一张图片被弄成.9之后加了些什么内容。

原图数据如下

原图.png

.9数据如下

.9图.png

可以看到,变成.9图片之后,多了5个IDAT块,而且参数里面的长宽都增加了2像素,而且图片大小也增加了不少从80kb增加到了180kb,我们可以猜测到,这几个块里面记录的数据就是我们生成.9图时画的那几条线生成的了。

其次 这些信息在图片发生拉伸时怎么被识别和使用的

这就涉及到android怎么加载一张图片的问题了,当然这些操作都是native层进行的,经过代码跟踪,我们发现有这样一个类 ** NinePatchPeeker.cpp**

我们来看看这个类里面的readChunk方法干了些什么

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;

}

我们看到这里处理了npTcnpLbnpOl三个数据块,当判断有npTc这个数据块的时候,系统就认为这是.9图片,就会进行下一步处理。

npTc这个数据又是从哪来的呢?

从上面内容我们知道已经添加了一些额外信息,我们发现官方的说明里有一句,要把.9图放在src/drawable目录下,这是因为在编译的时候,aapt会在发现图片名字符合.9图规则的时候,把四周的黑色边框提取出来,放在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;

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

    }

}

从注释我们看出,一张图片被分成了很多个部分,s开头的代表可以拉伸的,f开头的代表不能拉伸,还有一些颜色的数据,他们共同构成了这个结构体,x,y轴上这些可以拉伸和不可拉伸的部分分别放在xdivydivs数组里,同时内容的显示区域也由几个padding来存放,到这里,就把.9图的额外信息都给读出来了。

既然已经获得了这些额外的信息,那么绘制的时候,系统就可以根据这些信息判断怎么拉伸一张.9图了,绘制调用了NinePatchDrawable,最终也会走到native层去处理这些数据。

到此为止,制作.9和解析.9的过程就分析完毕了,一张.9图背后居然这么复杂,还是挺让人意外的,果然是学无止境啊。

.9图应用以及一个小坑

看完了怎么生成和它的原理 该我们去使用它了 回到开头 用我们新做的酷炫.9图解决问题吧!
代码张这样

图片.png
效果张这样

图片.png

为什么我们的.9图没有按照预期的拉伸,而是把内容也给拉伸了?因为.9图的特性问题,它只支持拉伸,如果一个图片本身就比imageview大了,那就不会去拉伸,所以我们的.9图本身太大了,也就不存在拉伸的说法了,那肯定是无效的。
那我们再来尝试把图片尺寸改小,这下可以拉伸了吧,来看看效果。

图片.png

可以看到,下面图标被遮挡住了,但是内容也太小了吧所以用.9图来做splash screen背景不是个好主意

那么,到底哪些场景可以用它呢?总结一下就是,一张图片,角落不能拉伸,其他部分可以随着内容的增多随便拉伸的情况,像这样。

图片.png

简单来说,就是聊天气泡一类的,如果一张图片的主体是内容要放缩的话,是不适合的,因为.9图只会拉伸特定部分,主体部分会维持原大小。

比如我们上面做背景图这种情况。

总结

  1. .9图并不适合作为全屏展示页面,要不太大了没有拉伸效果,要不内容太小了不美观
  2. .9图适合做一些纯色拉伸不形变的背景,比如聊天气泡和按钮背景这种
  3. 在android中,.9图得放到sr/drawable目录才行,系统才会去生成对应数据块,才可以被解析,否则就要自己去生成
  4. 普通图片由于没有额外的数据,是不能达到.9图这样的效果的

跟我一起玩Paging3

2022年10月8日 15:02

什么是Paging3

Paging 库可帮助您加载和显示来自本地存储或网络中更大的数据集中的数据页面。此方法可让您的应用更高效地利用网络带宽和系统资源。Paging 库的组件旨在契合推荐的 Android 应用架构,流畅集成其他 Jetpack 组件,并提供一流的 Kotlin 支持。
以上来自安卓官网,简单点来说,就是帮你处理列表分页加载工作的一个官方框架,从名字也能看出来。而且已经到达了第三个版本,paging3。

如何使用Paging3

使用paging3总共有以下几个步骤

  1. 定义PagingSource – 需要在这里处理如何获取数据和如何处理页面的号码
  2. 设置 PagingData 流 – 需要把pagingsource的实例和一些其他参数传递给构造器,比如每次加载的条目数量等等
  3. 定义 RecyclerView 适配器 – 需要继承PagingDataAdapter并提供DiffUtil.ItemCallback的实例
  4. 在界面中显示分页数据 – 在页面中绑定adapter
    通过以上几个步骤我们就可以使用paging3了,如果想看具体的步骤,可以移步官网,我们今天不用沉溺于具体的细节当中

    Paging3有哪些方便的地方

    毫无疑问,使用paging3会带给我们一些开发效率上的收益,回想一下我们以前是如何处理分页列表的,检测列表是否到达底部,使用自己持有的页数去做网络请求,把数据添加到原有的集合,更新列表。

是不是简单的回想一下就感觉到不少坑了呢,没错,我们自己做列表分页加载不仅繁琐无趣,还容易出问题。

这些工作交给paging3之后,我们只需要关注逻辑部分就可以了,如何获取数据,想以怎样的形式展示数据,这才是我们需要关注的焦点。

总结一下paging3的优点,替代了大量的琐碎分页加载代码,让逻辑和页面的耦合度降低,由官方的工程师保证了加载的正确性和效率。

Paging3有哪些不方便的地方

相信看了上面的优点之后大家都迫不及待的想去把以前的老代码干掉,换成这种看起来方便又省事的工具了。

但是在换之前,我这里先提几条使用paging3容易遇到的坑,帮助大家综合考量是不是真的要去使用这个工具框架。

  1. 对数据源有一定要求,有时候会提示indexoutofbounds,这种情况需要检查你提供的数据源,而不是adapter
  2. 使用paging3官方教程上需要继承PagingDataAdapter,这个问题可以通过装饰模式来解决,具体可以参考这篇文章
  3. paging3的pagingdataadapter虽然提供了refresh方法,但是调用之后数据源并没有获得通知,需要手动调用一次notifyDataSetChanged再去调用refresh才能让列表刷新之后继续触发分页加载,这是一个已知的bug,希望后期得到解决
  4. paging3的列表中由于写法的原因,对于多种不同item的展示存在一定困难性,如果现在已经有多item逻辑的话,修改起来比较麻烦

    Paging3的原理分析

    关于paging3的原理,网上的文章已经很多了,相信大家也不想看到我再重复长篇大论,这里放一张官方架构图给大家

paging3-library-architecture.svg
简单的总结一下原理就是,数据源把数据放入pager,pager使用flow(kotlin协程提供的对标rxjava的工具)将数据传递到adapter并处理和维护页数,adapter里面再使用DiffUtil.ItemCallback结合上自身的一些api对新来的数据做比对,再进行数据的处理,最后反应到页面上。

彻底摆脱数据线——远程ADB调试小工具开发过程记录

2022年10月8日 14:59

前排提示

本文中所描述工具只在ROOT过的设备上有效,如果不感兴趣的朋友可以点赞后退出了,也可以去github给我点个星星,源码地址在这里

写在开始前

每次重启测试机都要连接usb才能开始远程adb调试,真麻烦,能不能弄一个软件点一下就能开始远程调试呢?

如果对什么是adb远程调试不熟悉的朋友,可以搜索下adb tcpip 5555这条命令

为了解决每次重启都要连接一次电脑的问题,经过了大量的网络搜索,终于解决了这个问题。

本文记录了从提出问题到解决问题的全过程。

提问环节

  • 1.为什么要做这个工具?

    adb提供的远程调试工具不香吗,为什么还要没事找事折腾半天来做这个工具

  • 2.怎么实现不连接Usb启动adb的远程连接模式?

    通过往常的经验来看,各种实现远程调试的插件都需要连接一次usb,看起来不用usb好像不太现实,但是经过调研,确实是可以的

  • 3.找到方法了之后,执行哪些命令才能达到我们的目的?

    看似简单的几行命令,到了Android手机上怎么就老跑不通呢

    ** 带着上面这几个问题,我们开始今天的探索吧**

    为什么

    博主有一台很棒的测试机,它叫小6,小6有很多优点,速度快,体积小,操作简单。
    既然小6如此优秀,为什么还会有这篇博客呢,其实小6有一个致命问题,电池不耐用,毕竟已经退役成测试机了嘛。

    每次博主要远程调试的时候,小6都需要重新连一次电脑,对于一个电脑只有两个usb口的博主来说,这个过程非常的痛苦,特别是小6的电池又不耐用,每天晚上都会自动关机睡一觉。

    所以博主我想,能不能把连电脑这一个步骤转移到手机上执行呢,看着命令也挺简单的,只有一行

    感觉我上我也行嘛,说做就做,马上来试试

    adb tcpip 5555

    adb tcpip 5555 做了些什么

    这是一条神奇的命令,手机开启调试模式,连上电脑之后,运行它,我们就可以用ip连接手机,不用数据线调试程序了

经过一番查找,我们发现,这条命令发送了几条命令给手机

  • 1.设置service.adb.tcp.port为5555
  • 2.重启手机上的adb

搜到这里,博主激动了起来,看起来好像很简单啊,我在手机上也这么操作不就行了,马上去找找怎么在手机上执行命令行

在手机上运行它们!

Android上有很多命令行工具,博主随便找了一个,叫做Termux,运行了上面两条命令

报错了,not found 是什么鬼?经过一番搜索,原来是没有root权限

** 那我们只有去root手机了**

我们来看看root之后再运行怎么样

在获取root权限之后,报错消失了,太好了!

我们继续运行接下来两个命令

运行成功了,我们现在就可以在电脑上用下面的命令来连接手机了

adb connect 手机的ip:5555 

在自己的代码里运行命令

目前为止,一切都很顺利,我们不想用第三方的app,自己写一个程序,点一下,玩一年,不用数据线一整年

怎么在代码里运行shell命令呢?

经过又一番搜索,博主找到了几种方法,最终选定了下面这种,su可以替换成其他命令

 Runtime.getRuntime().exec("su")

让我们行动起来,运行三行命令吧

好像没有作用,尴尬

对比三方应用和自己的命令

通过对比,我们发现第三方的客户端的用户好像不太一样啊,经过搜索,我们发现是它调用了一下/system/bin/sh

我们也得给自己的代码加上才行

我们看看改完之后的命令

运行检测一下,成功啦!

优化一下使用体验

现在我们实现了基本的功能,但是不太友好,对于没有root的用户我们要提醒他,你不能用这个玩意,对于已经root的用户,操作成功了我们要提示他现在的ip是什么,用什么命令连接

这个很简单,弄完就像下面这样,其中的ip我们要去获取手机现在的ip地址

放出代码

前面比比了那么多,代码拿出来看看啊,别急,下面就是完整的代码

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
            val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
            val ip: String = Formatter.formatIpAddress(wm.connectionInfo.ipAddress)
            AlertDialog.Builder(this).setMessage("该程序仅工作在拥有root权限设备上,如果没有root,请连接usb并使用下列命令进行远程调试\nadb tcpip 5555\nadb connect $ip:5555").setPositiveButton(
                "确认"
            ) { _, _ ->
                val commands = arrayListOf(
                    "/system/bin/sh",
                    "setprop service.adb.tcp.port 5555",
                    "stop adbd",
                    "start adbd"
                )
                try {
                    RunAsRoot(commands)
                } catch (e: IOException) {
                    e.printStackTrace()
                }
            }.setCancelable(false).show()

    }

    private fun RunAsRoot(cmds: ArrayList<String>) {
        val p = Runtime.getRuntime().exec("su")
        val os = DataOutputStream(p.outputStream)
        for (tmpCmd in cmds) {
            os.writeBytes(
                """
                $tmpCmd

                """.trimIndent()
            )
        }
        os.writeBytes("exit\n")
        os.flush()
        val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
        val ip: String = Formatter.formatIpAddress(wm.connectionInfo.ipAddress)
        AlertDialog.Builder(this).setMessage("设置完成,请在同一局域网下使用以下命令连接设备\nadb connect $ip:5555").setPositiveButton(
            "确认"
        ) { _, _ -> finish() }.setCancelable(false).show()
    }

}

相信看完上面的介绍,代码应该不难看懂,如果不想自己写,可以去这里找到源码,直接复制粘贴一把梭

问题回顾

让我们来看看最开始的几个问题,总结一下

  • 1.为什么要做这个工具?

    我们的测试机可以重启后不连接usb启动远程调试模式啦!

  • 2.怎么实现不连接Usb启动adb的远程连接模式?

    在代码里运行命令就可以了,用Runtime.getRuntime().exec(你的命令)

  • 3.找到方法了之后,执行哪些命令才能达到我们的目的?

    下面这些

         su
         /system/bin/sh
         setprop service.adb.tcp.port 5555
         stop adbd
         start adbd

写在最后

终极问题,能不能不用root实现这套操作?不能,即使设置端口成功了,重启手机上adb的操作也需要su权限,所以不root是不行的,遗憾

那么大家下次再见

Android测试体系-在MVVM架构中如何测试Model层与ViewModel层

2022年10月8日 14:58

背景

此文章是对于google code lab中《Introduction to Test Double and Dependence injection》 与 《Testing Basics》的总结,本篇主要讲述如何在mvvm架构的android项目中对Model层以及ViewModel层进行测试

Model层

为什么要测它

model层作为数据获取层,主<span style="display: inline-block; width: 0px; overflow: hidden; line-height: 0;" data-mce-type="bookmark" class="mce_SELRES_start"></span>要与network和数据库打交道,我们需要测试其对数据的获取和更新操作逻辑的正确性

测它的时候会遇到什么问题

如上所述,Model层通常和数据库和网络有较强相关性,我们需要测试的只是其对数据的处理逻辑。

如何解决

改变数据源的获取方式,不要使用内部构造的方式,采用依赖注入方法来进行注入
这是通常写法的Repository代码,里面的dataSource是在内部构建,这就造成了测试的时候难以去除逻辑和数据源的耦合,造成无法进行测试

<pre class="theme:github lang:default decode:true " >class DefaultTasksRepository private constructor(application: Application) {

private val tasksRemoteDataSource: TasksDataSource
private val tasksLocalDataSource: TasksDataSource

// Some other code

init {
val database = Room.databaseBuilder(application.applicationContext,
ToDoDatabase::class.java, "Tasks.db")
.build()

tasksRemoteDataSource = TasksRemoteDataSource
tasksLocalDataSource = TasksLocalDataSource(database.taskDao())
}
// Rest of class
}</pre>

下面是使用构造注入方式的代码

<pre class="theme:github lang:default decode:true " >class DefaultTasksRepository(
private val tasksRemoteDataSource: TasksDataSource,
private val tasksLocalDataSource: TasksDataSource,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { // Rest of class }</pre>

这样我们就实现了解耦,可以在单元测试中进行测试了

为了实现测试,我们需要自己实现一个fakeDataSource,里面对虚拟数据集合进行维护

在测试的时候,我们直接使用fakeDataSource进行

完整代码:

<pre class="theme:github lang:default decode:true " >@ExperimentalCoroutinesApi
class DefaultTasksRepositoryTest {
private val task1 = Task("Title1", "Description1")
private val task2 = Task("Title2", "Description2")
private val task3 = Task("Title3", "Description3")
private val remoteTasks = listOf(task1, task2).sortedBy { it.id }
private val localTasks = listOf(task3).sortedBy { it.id }
private val newTasks = listOf(task3).sortedBy { it.id }

private lateinit var tasksRemoteDataSource: FakeDataSource
private lateinit var tasksLocalDataSource: FakeDataSource

// Class under test
private lateinit var tasksRepository: DefaultTasksRepository

@Before
fun createRepository() {
tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
// Get a reference to the class under test
tasksRepository = DefaultTasksRepository(
tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
)
}
@Test
fun getTasks_requestsAllTasksFromRemoteDataSource() = runBlockingTest{
val tasks = tasksRepository.getTasks(true) as Result.Success
assertThat(tasks.data,IsEqual(remoteTasks))
}
}</pre>

ViewModel层

为什么要测它

作为程序逻辑的主要控制中心,对viewmodel进行测试保证逻辑正确是很有必要的

测它的时候会遇到什么问题

作为View与Model的中间层,ViewModel测试中最大的问题是以下两点

  1. 双向绑定的LiveData怎么测

  2. 怎么解决与Model层的依赖问题,如何使用假数据来测试逻辑的正确性

    如何解决

  3. 双向绑定的LiveData怎么测
    使用以下工具类利用countdownlatch来将异步过程变为同步过程,从而同步获取livedata的值

    <pre class="lang:default decode:true " >@VisibleForTesting(otherwise = VisibleForTesting.NONE)
    fun <T> LiveData<T>.getOrAwaitValue(
    time: Long = 2,
    timeUnit: TimeUnit = TimeUnit.SECONDS,
    afterObserve: () -> Unit = {}
    ): T {
    var data: T? = null
    val latch = CountDownLatch(1)
    val observer = object : Observer<T> {
    override fun onChanged(o: T?) {
    data = o
    latch.countDown()
    this@getOrAwaitValue.removeObserver(this)
    }
    }
    this.observeForever(observer)

    try {
    afterObserve.invoke()

    // Don't wait indefinitely if the LiveData is not set.
    if (!latch.await(time, timeUnit)) {
    throw TimeoutException("LiveData value was never set.")
    }

    } finally {
    this.removeObserver(observer)
    }

    @Suppress("UNCHECKED_CAST")
    return data as T
    }</pre>

  4. 怎么解决与Model层的依赖问题,如何使用假数据来测试逻辑的正确性
    使用本文测试Model层的方法,构建一个FakeRepository来传入ViewModel的构造方法,需要注意,此时在Fragment或者Activity中构造ViewModel方式有所改变,如下代码

Fragment

<pre class="lang:default decode:true " >class TasksFragment : Fragment() {

private val viewModel by viewModels<TasksViewModel> {
TasksViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
}
//...
}</pre>

ViewModel

<pre class="lang:default decode:true " >class TasksViewModel(
private val tasksRepository: TasksRepository
) : ViewModel() {
//...
}

@Suppress("UNCHECKED_CAST")
class TasksViewModelFactory (
private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>) =
(TasksViewModel(tasksRepository) as T)
}
</pre>

完整代码

<pre class="lang:default decode:true " >@RunWith(AndroidJUnit4::class)
class TasksViewModelTest{
// Subject under test
private lateinit var tasksViewModel: TasksViewModel
private lateinit var tasksRepository: FakeTestRepository

// Executes each task synchronously using Architecture Components.
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()

@Before
fun setupViewModel() {
tasksRepository = FakeTestRepository()
val task1 = Task("Title1", "Description1")
val task2 = Task("Title2", "Description2", true)
val task3 = Task("Title3", "Description3", true)
tasksRepository.addTasks(task1, task2, task3)

tasksViewModel = TasksViewModel(tasksRepository)
}

@Test
fun addNewTask_setsNewTaskEvent() {
// Given a fresh TasksViewModel
// When adding a new task
tasksViewModel.addNewTask()
// Then the new task event is triggered
val value =tasksViewModel.newTaskEvent.getOrAwaitValue()
assertThat(value.getContentIfNotHandled(),(not(nullValue())))

}
@Test
fun setFilterAllTasks_tasksAddViewVisible() {

// Given a fresh ViewModel
// When the filter type is ALL_TASKS
tasksViewModel.setFiltering(TasksFilterType.ALL_TASKS)
// Then the "Add task" action is visible
val value = tasksViewModel.tasksAddViewVisible.getOrAwaitValue()
assertThat(value,is(true))
}
}</pre>

为什么不做ui测试

官方给出了一种ui测试的解决方案,但是测试范围仅限于ui是否显示以及ui文字等,完全可以用人工测试替代,而且ui改动后测试用例改动比较频繁,所以ui测试个人觉得没有必要写作单元测试的模式

❌
❌