0%

需求场景

通过固定的 url 加载图片, 在 url 不变的情况下, 服务器可能会更新 url 对应的资源, 客户端需要能够及时检测到图片的更新并加载最新资源.

服务器能够提供的交互

  1. 在正常情况下, 加载成功后, 返回 200 成功, Response Body 返回对应的图片资源, 同时 Response Header 中带上 etag 表示该 url 对应资源的特征值. 如:

    1
    etag:"bd6d0ff25778318b238ac530cb147247"
  2. 后续的请求, Request Header 带上 if-none-match 给服务器检查是否更新, 如果图片资源未更新, 直接返回 304, 否则正常返回.

    1
    2
    3
    4
    5
    6
    7
    // Request Header
    if-none-match: "bd6d0ff25778318b238ac530cb147247"
    //

    // Response (未改变)
    Statue Code : 304
    //

客户端实现

客户端需要

  1. 本地缓存 url 对应的 etag, 同时在后续请求的时候带上.
  2. 加载图片的时候需要支持给 url 设置 Header
  3. 在图片加载成功后, 如果是远程 url 也需要读取 Response 的 Header.

由于客户端一般都有通用的图片加载库, 所以 2,3 点需要图片库支持

本地缓存 url 对应的 etag

这个使用通用的 Key-Value 缓存都能实现, 可以用 SharedPrference, sqlite, MMKV 或 DataStore 都能实现, 我比较推荐用 MMKV 的形式, 最主要的特点是不需要切线程, 同时也是属于能够接受丢失的数据.

图片库的支持

这里以 Glide 举例, 常见的写法是

1
2
3
4
5
Glide.with()
.asDrawable()
.load(url)
.signature(etag)
.into()
  1. 设置 Header, 这个比较简单, 可以通过自带的 GlideUrl 设置
    1
    2
    3
    4
    5
    val header = LazyHeaders.Builder()
    .addHeader("if-none-match", etag)
    .build()
    val glideUrl = GlideUrl(url, header)
    Glide.load(glideUrl)
  2. Glide 取出 Http Response Header 中的 etag.
    我这边想到的办法比较麻烦:
    1. 调整 Glide 的网络库实现为 OKHttp
    2. 给 Glide 设置 OKHttpClient 时添加自定义的 Interceptor , 然后就可以取出 Response 中的 etag.

但是这样有个问题, 就是 etag 无法与 Glide 的 Request 进行关联, 导致同一个 url 必定会有两次网络请求.

  1. 第一次加载, 由于没有 etag 缓存, signature 传入的 etag 为 null. 此时 Glide 图片以 url 作为缓存 key.
  2. 第一次加载后, response 中取出 etag , 记录与 url 的映射关系.
  3. 第二次加载, signature 传入 url 对应的 etag, 由于该 etag 缓存 key 未找到对应的缓存图片, 所以又会发起一次网络请求.

所以纯粹修改 Glide 图片库似乎不太好.

先发起一次网络请求

由于图片正常返回时, Http Body 就是所需加载的图片资源 byte, 所以可以先发起一次网络请求, 拿到 etag 后, 设置 signature 并使用 Glide.load(byte[]) 方法直接加载 Http 的 Body, 这样只有一次网络请求.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
val request = Request.Builder()
.url(url)
.build()
client.newCall(request).enqueue(object: Callback {
override fun onFailure(call: Call, e: IOException) {
// Error
}

override fun onResponse(call: Call, response: Response) {
if (!response.isSuccessful) {
return
}
val eTag = response.header("etag") ?: url
Glide.with(getContext()).asDrawable()
.load(response.body?.bytes() ?: ByteArray(0)))
.signature(ObjectKey(etag))
.into()
}

但是这样测试之后, 发现有新的问题, 即第二次请求虽然 etag 都匹配传入了, 但是依然发起了网络请求. 排查之后发现 Glide.load(byte[]) 方法使用的 ByteArrayLoader 中定义的 DataSource 为 Local, 所以使用该方法就不会写入本地缓存, 第二次请求就不会命中缓存.

解决办法也比较简单, 可以仿照 ByteArrayLoader 写一个自定义的 Loader , 然后注册到 Glide 中, 注意替换 DataSource 为 Remote.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/**
* 主要用于直接加载 HttpBody
* 配合 ETag 使用
*/
class ETagByteModel(val eTag: String, val data: ByteArray): Key {

override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(eTag.toByteArray(Key.CHARSET))
}

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false

other as ETagByteModel

if (eTag != other.eTag) return false

return true
}

override fun hashCode(): Int {
return eTag.hashCode()
}
}

/**
* 参考 ByteArrayLoader 实现
* 调整 DataSource 为 remote, 这样可以走缓存逻辑
*/
class ETagByteLoader<Data>(val converter: ByteArrayLoader.Converter<Data>) : ModelLoader<ETagByteModel, Data> {

override fun buildLoadData(model: ETagByteModel, width: Int, height: Int, options: Options): ModelLoader.LoadData<Data>? {
return ModelLoader.LoadData(model, Fetcher(model.data, converter))
}

override fun handles(model: ETagByteModel): Boolean {
return true
}

private class Fetcher<Data>
/**
* @param model We really ought to copy the model, but doing so can be hugely expensive and/or
* lead to OOMs. In practice it's unlikely that users would pass an array into Glide and
* then mutate it.
*/ internal constructor(private val model: ByteArray, private val converter: ByteArrayLoader.Converter<Data>) : DataFetcher<Data> {
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Data>) {
val result = converter.convert(model)
callback.onDataReady(result)
}

override fun cleanup() {
// Do nothing.
}

override fun cancel() {
// Do nothing.
}

override fun getDataClass(): Class<Data> {
return converter.dataClass
}

override fun getDataSource(): DataSource {
// 注意 DataSource 的值
return DataSource.REMOTE
}
}

}

/**
* 对应 InputStream 解析
*/
class StreamFactory : ModelLoaderFactory<ETagByteModel, InputStream> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<ETagByteModel, InputStream> {
return ETagByteLoader<InputStream>(
object : ByteArrayLoader.Converter<InputStream> {
override fun convert(model: ByteArray?): InputStream {
return ByteArrayInputStream(model)
}

override fun getDataClass(): Class<InputStream> {
return InputStream::class.java
}
})
}

override fun teardown() {
// Do nothing.
}
}

然后注册到 Glide

1
2
3
4
5
6
7
@GlideModule
public final class EtagGlideModule extends LibraryGlideModule {
@Override
public void registerComponents(Context context, Glide glide, Registry registry) {
registry.replace(ETagByteModel.class, InputStream.class, new StreamFactory());
}
}

然后加载的时候调用

1
2
3
4
5
val eTag = response.header("etag") ?: url
Glide.with(getContext()).asDrawable()
.load(EtagByteModel(etag, response.body?.bytes() ?: ByteArray(0)))
.signature(ObjectKey(etag))
.into()

这样即可以实现 etag 更新, 也可以利用到 Glide 的缓存, 对项目修改比较小.

最终流程

  1. 首先检查是否有 url 对应的 etag 缓存
    • 如果没有缓存, 先发起一次网络请求, 成功之后利用 Glide 设置 signature 加载 http body.
    • 如果有缓存
      1. 先通过Glide 设置 signature 加载 url
      2. 在 Glide 的成功回调里面带上 if-none-match 的 Header 发起网络请求, 用于检查服务器是否更新了图片.

预加载与缓存的坑

如果使用 Glide 的 preload 方法并且缓存了 preload 返回的 Drawable, 需要注意 Drawable 中的 Bitmap 对象在 preload 完成后会释放掉, 此时 Bitmap 会被 Glide 回收后复用给其他请求.
如果自己缓存了该 Bitmap, 会导致两种结果:

  1. Bitmap 错误展示了其他地方请求的结果, 即展示的图片与实际的 url 对不上.
  2. Bitmap 复用 Crash
    1
    2
    3
    4
    5
    6
    7
    8
    9
    java.lang.RuntimeException
    Canvas: trying to use a recycled bitmap android.graphics.Bitmap@111bacc
    android.graphics.BaseCanvas.throwIfCannotDraw(BaseCanvas.java:77)
    android.graphics.MiuiCanvas.throwIfCannotDraw(MiuiCanvas.java:329)
    android.graphics.RecordingCanvas.throwIfCannotDraw(RecordingCanvas.java:277)
    android.graphics.BaseRecordingCanvas.drawBitmap(BaseRecordingCanvas.java:88)
    android.graphics.drawable.BitmapDrawable.draw(BitmapDrawable.java:548)
    android.widget.ImageView.onDraw(ImageView.java:1434)
    ...
    解决办法是自己缓存的场景不要用 preload, preload 的实现也是 CustomTarget, 并且在 preload 成功后 clear 了资源. 所以我们使用 into CustomTarget 即可.
1
2
3
4
5
6
7
8
9
10
.into(object: CustomTarget<Drawable>() {
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
// 保存 Drawable
}

override fun onLoadCleared(placeholder: Drawable?) {
// 释放 Drawable
}

})

Dialog 使用的常见坑.

  1. dismiss 错误堆栈
1
2
3
4
5
java.lang.IllegalArgumentException View=DecorView@984ba6d[MainActivity] not attached to window manager
android.view.WindowManagerGlobal.findViewLocked(WindowManagerGlobal.java:619)
android.view.WindowManagerGlobal.removeView(WindowManagerGlobal.java:511)
android.view.WindowManagerImpl.removeViewImmediate(WindowManagerImpl.java:200)
android.app.Dialog.dismissDialog(Dialog.java:766)

这里查看堆栈是 DIalog dismiss 的堆栈, 但是一般看不到调用 dialog.dismiss 的业务堆栈.

主要是因为这种情况下一般是子线程调用了 dismiss, 但是 Dialog 的 dismiss 会有一个切线程的操作:

1
2
3
4
5
6
7
8
@Override
public void dismiss() {
if (Looper.myLooper() == mHandler.getLooper()) {
dismissDialog();
} else {
mHandler.post(mDismissAction);
}
}

其中 mHandler 对象是在 Dialog 创建的时候初始化的, 所以 Looper 也是和创建 Dialog 的时候的线程绑定的, 同时因为 Handler 是 private 的, 所以目前想到的 hook 方式是自己同步创建一个 Handler , 在调用 dismiss 时自己切线程, 这样就可以正确 try catch 这个 Exception.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MyDialog(context: Context): Dialog(context) {

private val handler = Handler()

override fun dismiss() {
if (Looper.myLooper() == handler.looper) {
superDismiss()
} else {
handler.post { superDismiss() }
}
}

private fun superDismiss() {
try {
super.dismiss()
} catch (e: Exception) {
// catch success
}
}
}

ViewCacheExtension 作为 RecyclerView 中的开发者自定义缓存, 具有以下特点:

  1. 只有从缓存中取, 没有从缓存中读.
  2. 接口需要返回 View.
  3. 接口返回的 View 必须绑定了 ViewHolder .

就应用来说, 我觉得有两个合适的场景

Read more »

分区

根据 “Java 虚拟机规范”, Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域.

而其实一个 Java 程序可以理解为一个进程, 进程里面的资源共享即区分以下区域的私有和共享.

私有:

  • 程序计数器: 当前线程所执行的字节码的行号指示器.
  • 虚拟机栈(VM Stack): 每个方法在执行时会创建一个栈帧来存储局部变量表, 操作数栈, 动态链接, 方法出口等信息. 主要是局部变量表, 存储了各种基本数据类型和对象引用.
  • 本地方法栈(Native Method Stack): 对于 Native 方法的存储.

共享:

  • Java 堆: 几乎所有的对象示例都要在这里分配内存, 也是 GC 的主要管理区域.
  • 方法区: 存储虚拟机加载的类信息, 常量, 静态变量, JIT 编译之后的代码.

回收

GC 方案常见的是 引用计数法根搜索算法.

  • 引用记数法: 给对象添加一个引用计数器, 被引用时计数器 +1, 引用失效时计数器 -1. 如果任何时刻计数器都为0, 那就不可能再被使用, 就可以被回收. 因为引用计数法在循环引用时会导致对象无法回收, 所以目前被弃用了.
  • 可达性算法: 目前认为比较成熟的算法, 即从 GC Roots 的对象作为起点, 向下搜索对象, 走过的路径称为引用链, 如果某个对象到 GC Roots 之间没有任何引用链, 那认为这个对象是不可达的, 它就可以被回收.

GC Roots 有以下几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象.
  2. 方法区中类静态属性引用的对象.
  3. 方法区中常量引用的对象.
  4. 本地方法栈中 JNI 引用的对象.

对象引用

涉及到内存回收, Java中对对象的引用可以使用以下四种,分别为强引用,软引用,弱引用,虚引用。
参考地址.

  1. 强引用(StrongReference)

强引用是使用最普遍的引用,如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。**(默认的引用方式)**

  1. 软引用(SoftReference)

如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存**(即不抛出OOM错误)**。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存(下文给出示例)。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中.

  1. 弱引用(WeakReference)

弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此 不一定会很快发现那些只具有弱引用的对象。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

  1. 虚引用(PhantomReference)

“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。 使用虚引用只是为了能够在对象回收时收到通知.

ReferenceQueue的用法(以PhantomReference为例):
ReferenceQueue queue = new ReferenceQueue (); 
PhantomReference pr = new PhantomReference (object, queue);

名字 | 引用方式 | GC是否回收 | 是否 OOM
— | — | — | —
强引用 | 直接调用 | 否 | 是
软引用 | .get | 看内存情况 | 否
弱引用 | .get | 是 | 否
虚引用 | null | 是 | 否

回收

标记-清除算法

即回收算法分为”标记”和”清除”两个阶段, 先扫描一次标记所有需要回收的对象, 再扫描一次回收所有被标记地对象.

缺点:

  • 效率太低
  • 会产生大量不连续地内存碎片, 这些碎片会导致无法给大内存对象分配内存.

复制算法

将可用内存分为大小相等的两块, 每次只使用其中的一块. 回收时把存活地对象复制到另一块区域即可, 其他空间都可以清理掉.

缺点:

  • 可用内存直接变成一半了.

标记-整理算法

先扫描一次标记所有需要回收的对象, 再让所有的对象都向一端移动, 最后清理掉边界以外的内存.

分代回收

现在的商业虚拟机大部分都是分代回收算法, 分为新生代(young gen)和老生代(old gen), 其中 young gen 又分为 eden 区, from survior 和 to survior 区. 新生代的 GC 叫做 Minor GC, 老生代的 GC 叫做 Major GC / Full GC . 数据一开始会分配到 Eden 区(大对象直接进入 old gen), young gen 采用的是复制算法, 因为 young gen 的大部分数据都是回马上死亡的, 所以只需复制少部分存货的对象从 From survior 到 To survior. 当 young gen 的数据经历了几次 GC 后(默认15次), 它会从 young gen 移到 old gen. 而 old gen 采用的是标记-整理算法, 可以应用 old gen 中对象 100% 都存活的情况.

GC 日志

33.125: [GC [DefNew: 3324K->152K(3712K), 0.0025925 secs] 3324K->152K(11904K), 0.0031680 secs]

  • 33.125: Java 虚拟机启动以来经过的秒数.
  • GC 开头: 区分 GC 区域, 像这里的 DefNew 是 Serial 收集器的新生代区域, 还有 Full GC 表示全部 GC.
  • 3324K -> 152K(3712K): GC 前该内存区域的可用容量 -> GC 后该内存区域已使用容量(该内存区域总容量)
  • 0.0025925 secs: 该区域 GC 所占用的时间.
  • 3324K->152K(11904K): GC 前 Java 堆已使用容量 -> GC 后 Java 堆已使用容量(Java 堆总容量)

WorkManager-Guide&Tips

WorkManager 为了方便运行一些不着急的异步的后台任务而诞生. 大部分情况下, 只需要定义好自己想做的任务, 交给 WorkManager 去执行, 剩下就不用管了.

注意一下, 同样是后台线程, WorkManager 的重点在于保证就算 App 关掉之后后台任务也能够被执行. 而那种可以随着 App 退出而关闭的后台任务, 还是更适合使用 ThreadPools.

以前的实现方案

  1. Service: 这是最常见的需要后台运行的方案了. 对比来说, Service 有以下几个问题:

    • 可能会由于开发者的设置而疯狂运行, 这会导致手机电量被疯狂消耗. 对比之下 WorkManager 的同一个周期任务的最小间隔时间是15分钟.
    • targetSdkVersion 为 26 及以上的时候, 在不被允许创建后台服务的情况下, startService() 会抛出 IllegalStateException. 对比之下 WorkManager 会按照设定选择合适的时间运行.
  2. JobScheduler: 这个最关键的就是只有 Android 5.0 以上才能使用, 其实 WorkManager 在 5.0 以上也是用这个实现的.

  3. AlarmManager + BroadcastReceiver. 这个方案也是可以的, WorkManager 在 5.0 以下也是这样实现的, 只是封装了更好用的 API .

如果对更好用的 WorkManager 感兴趣, 就可以继续往下看了. 大概的介绍顺序是:

  1. 导入
  2. 一次性任务的使用
  3. 周期性任务的使用
  4. 任务如何取消
  5. 给任务加上约束条件
  6. 多个任务以特定顺序执行
  7. 相同任务的重复处理策略
  8. 任务的输入和输出
  9. 一些需要注意的点

以下代码都可以在 Demo 中找到.

导入

和其他 JetPack 的组件一样, 在 projectbuild.gradle 文件中添加 google() 源:

1
2
3
4
5
6
allprojects {
repositories {
google()
jcenter()
}
}

然后在 modulebuild.gradle 中添加 WorkManager 的依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
dependencies {
def work_version = "1.0.0-beta05"

// Java 依赖版本
implementation "android.arch.work:work-runtime:$work_version"

// Kotlin 依赖版本, 和上面的依赖二选一即可
implementation "android.arch.work:work-runtime-ktx:$work_version"

// 可选 RxJava2 支持
implementation "android.arch.work:work-rxjava2:$work_version"

// 可选 测试支持
androidTestImplementation "android.arch.work:work-testing:$work_version"
}

基本使用

使用起来就如上面所说, 首先你需要创建一个 任务 (Worker) , 然后丢给 WorkManager.

1
2
3
4
5
6
7
8
9
10
11
12
13
Kotlin
class TestWorker(context: Context, params: WorkerParameters)
: Worker(context, params) {

override fun doWork(): Result {

// 这里已经是后台线程了, 只需要实现自己的业务逻辑就好了
// return Result.retry(); 重试
// return Result.failure(); 不再重试
return Result.success()
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Java
public class TestWorker extends Worker {

public TestWorker(Context context, WorkerParameters params) {
super(context, params);
}

@Override
public Result doWork() {

// 这里已经是后台线程了, 只需要实现自己的业务逻辑就好了
// return Result.retry(); 重试
// return Result.failure(); 不再重试
return Result.success();
}
}

Worker 里面只声明要实现的任务, 其他的约束条件要在 WorkRequest 中设置, 把 Worker 变成 WorkRequest. 再交给 WorkManager 去执行就好了.

一次性任务

1
2
3
Kotlin
val oneTimeWorker = OneTimeWorkRequest.Builder(TestWorker::class.java).build()
WorkManager.getInstance().enqueue(oneTimeWorker)
1
2
3
4
Java
OneTimeWorkRequest oneTimeWorker =
new OneTimeWorkRequest.Builder(TestWorker.class).build();
WorkManager.getInstance().enqueue(oneTimeWorker);

就是这么简单, 接下来 Worker 就会在后台线程运行了.

周期性任务

周期性任务需要更加慎重一点. 开启之后如果不注意, 大部分情况下就会一直运行, 这可能带来很不好的用户体验.

设置周期性任务的时候, 需要设置 repeatInterval(重复区间)flexInterval(弹性区间) 参数, 配合注释说明:

1
2
3
4
5
[  弹性区间外  |  弹性区间内 (flex Interval) ][  弹性区间外  |  弹性区间内 (flex Interval) ]...
[ 任务不运行 | 任务可运行 ][ 任务不运行 | 任务可运行 ]...
\_________________________________________/\________________________________________/...
第一个区间 (repeat Interval) 第二个区间 (repeat Interval) ...(repeat)

repeatInterval 最小值是15分钟, 而 flexInterval 的最小值是5分钟, 如果 flexInterval 大于 repeatInterval, 也会被修改到和 repeatInterval 一样的值.

取消任务

但是从 API 可以看到, WorkManager 是将这个 Worker 入队了, 那既然是以队列维护的异步操作, 肯定会有重复的问题. WorkManager 默认的操作是遇到一样的 Worker 时, 新 Worker 会等旧 Worker 运行完再运行, 即顺序执行.

不过大部分情况下这都不是我们想要的模式, 所以在运行前最好取消相同的任务. 每个 Worker 都有一个唯一标识 UUID, 同时在构建 WorkRequest 的时候还可以添加任意个 Tag, 通过这两个标识都可以取消任务.

1
2
3
4
5
6
7
8
9
10
Kotlin
// UUID 方式
val workId: UUID = oneTimeWorker.getId()
WorkManager.getInstance().cancelWorkById(workId)

// Tag 方式
val oneTimeWorker = OneTimeWorkRequest.Builder(TestWorker::class.java)
.addTag("myTag")
.build()
WorkManager.getInstance().cancelAllWorkByTag("myTag")
1
2
3
4
5
6
7
8
9
10
Java
// UUID 方式
UUID workId = oneTimeWorker.getId();
WorkManager.getInstance().cancelWorkById(workId);

// Tag 方式
OneTimeWorkRequest myTask = new OneTimeWorkRequest.Builder(TestWorker.class)
.addTag("myTag")
.build()
WorkManager.getInstance().cancelAllWorkByTag("myTag")

取消相同的任务已经避免了系统资源不必要的消耗, 不过为了防止 API 的滥用, 还推荐给任务加上一些约束条件, 方便任务在系统资源没那么紧张的时候再执行:

加上约束

所有的约束 Constraints 都是由 Constraints.Builder() 来创建的, Builder 提供了以下的约束方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
Kotlin
// 设置网络类型
setRequiredNetworkType(networkType: NetworkType)
// 是否运行时电量不要太低
setRequiresBatteryNotLow(requiresBatteryNotLow: Boolean)
// 是否在充电时才运行
setRequiresCharging(requiresCharging: Boolean)
// 是否不太剩余存储空间过低时运行
setRequiresStorageNotLow(requiresStorageNotLow: Boolean)
// 是否在设备空闲时运行, 这个最低版本是 23
setRequiresDeviceIdle(requiresDeviceIdle: Boolean)
// 监听一个本地的 Uri, 第二个参数是否监听 Uri 的子节点. 在 Uri 的内容改变时运行任务, 最低版本是 24
addContentUriTrigger(uri: Uri, triggerForDescendants: Boolean)
1
2
3
4
5
6
7
8
9
10
11
12
13
Java
// 设置网络类型
setRequiredNetworkType(NetworkType networkType)
// 是否运行时电量不要太低
setRequiresBatteryNotLow(boolean requiresBatteryNotLow)
// 是否在充电时才运行
setRequiresStorageNotLow(boolean requiresStorageNotLow)
// 是否不太剩余存储空间过低时运行
setRequiresStorageNotLow(requiresStorageNotLow: Boolean)
// 是否在设备空闲时运行, 这个最低版本是 23
setRequiresDeviceIdle(boolean requiresDeviceIdle)
// 监听一个本地的 Uri, 第二个参数是否监听 Uri 的子节点. 在 Uri 的内容改变时运行任务, 最低版本是 24
addContentUriTrigger(Uri uri, boolean triggerForDescendants)

多个任务的执行顺序

WorkManager 提供了相应的 API 使任务可以使一个或多个 OneTimeWorkerRequest 按某个顺序执行.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// A, B, C 就会按顺序执行, 如果全部返回成功或者某一个返回失败, 那该任务链就会结束.
WorkManager.getInstance()
.beginWith(workA)
.then(workB)
.then(workC)
.enqueue()

// A, B 一起运行, 虽然这2个的开始顺序不定, 但是 C 一定是在这2个运行后才运行.
WorkManager.getInstance()
.beginWith(Arrays.asList(workA, workB))
.then(workC)
.enqueue()

// B 一定会在 A 后面运行, D 也一定会在 C 后面运行, 但是 AB 与 CD 这两条链的运行顺序不定, 但是 E 一定是在 B 和 D 都结束后才运行.
val chain1 = WorkManager.getInstance()
.beginWith(workA)
.then(workB)
val chain2 = WorkManager.getInstance()
.beginWith(workC)
.then(workD)
val chain3 = WorkContinuation
.combine(Arrays.asList(chain1, chain2))
.then(workE)
chain3.enqueue()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// A, B, C 就会按顺序执行, 如果全部返回成功或者某一个返回失败, 那该任务链就会结束.
WorkManager.getInstance()
.beginWith(workA)
.then(workB)
.then(workC)
.enqueue();

// A, B 一起运行, 虽然这2个的开始顺序不定, 但是 C 一定是在这2个运行后才运行.
WorkManager.getInstance()
.beginWith(Arrays.asList(workA, workB))
.then(workC)
.enqueue();

// B 一定会在 A 后面运行, D 也一定会在 C 后面运行, 但是 AB 与 CD 这两条链的运行顺序不定, 但是 E 一定是在 B 和 D 都结束后才运行.
WorkContinuation chain1 = WorkManager.getInstance()
.beginWith(workA)
.then(workB);
WorkContinuation chain2 = WorkManager.getInstance()
.beginWith(workC)
.then(workD);
WorkContinuation chain3 = WorkContinuation
.combine(Arrays.asList(chain1, chain2))
.then(workE);
chain3.enqueue();

相同任务的重复策略

前面提到对于 Worker 来说, 可以通过 UUID 和 Tag 来保证其唯一性, 这样在需要的时候就可以避免任务重复执行. 但对于连续的任务链, 如果任务多了, 这样的方式会很繁琐. 于是, WorkerManager 也提供了相应的 API 来保证其唯一性.

1
2
3
4
Kotlin
beginUniqueWork(uniqueWorkName: String, existingWorkPolicy: ExistingWorkPolicy, work: OneTimeWorkRequest): WorkContinuation

beginUniqueWork(uniqueWorkName: String, existingWorkPolicy: ExistingWorkPolicy, work: List<OneTimeWorkRequest>): WorkContinuation
1
2
3
4
Java
WorkContinuation beginUniqueWork(String uniqueWorkName, ExistingWorkPolicy existingWorkPolicy, OneTimeWorkRequest work)

WorkContinuation beginUniqueWork(String uniqueWorkName, ExistingWorkPolicy existingWorkPolicy, List<OneTimeWorkRequest> work)

第一个参数就是这一个或者一系列 worker 的名字, 第二个参数就是重复时的操作, 有以下几种模式:

  • ExistingWorkPolicy.APPEND : 如果上一个任务处于等待或者未完成的状态, 则把当前任务添加到其任务链的后面. 这样它就在上一个任务执行完后执行.
  • ExistingWorkPolicy.KEEP : 如果上一个任务处于等待或者未完成的状态, 什么都不做(继续等上一个任务执行).
  • ExistingWorkPolicy.REPLACE : 如果上一个任务处于等待或者未完成的状态, 取消并删除上一个, 执行新的.

输入和输出

Worker 的输入输出是用 Map<String, Object> 来存储的, 用 Data 类封装了一层. 输出用 LiveData 来监听.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Kotlin

// 创建输入
val inputData = Data.Builder()
.putInt("KEY_FIRST", firstNumber)
.putInt("KEY_SECOND", secondNumber)
.build()
val worker = OneTimeWorkRequestBuilder<MathWorker>()
.setInputData(inputData)
.build()
WorkManager.getInstance().enqueue(worker)

// Worker 类:
class PlusWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {

override fun doWork(): Result {
val first = inputData.getInt("KEY_FIRST", 0)
val second = inputData.getInt("KEY_SECOND", 0)
val result = first + second // 1 + 2 = 3
val output = Data.Builder()
.putInt("KEY_RESULT", result)
.build()
return Result.success(output)
}
}

// 监听返回
WorkManager.getInstance().getWorkInfoByIdLiveData(worker.id)
.observe(this, Observer { info ->
if (info != null && info.state.isFinished) {
// 获取返回结果, 应该是3
val result = info.outputData.getInt("KEY_RESULT", 0)
}
})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Java
// 创建输入
Data inputData = new Data.Builder()
.putInt("KEY_FIRST", 1)
.putInt("KEY_SECOND", 2)
.build();

OneTimeWorkRequest worker = new OneTimeWorkRequest.Builder(PlusWorker.class)
.setInputData(inputData)
.build();
WorkManager.getInstance().enqueue(worker);

// Worker 类:
public class PlusWorker extends Worker {

public PlusWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}

@NonNull
@Override
public Result doWork() {
int first = getInputData().getInt("KEY_FIRST", 0);
int second = getInputData().getInt("KEY_SECOND", 0);
int result = first + second; // 1 + 2 = 3
Data output = new Data.Builder()
.putInt("KEY_RESULT", result)
.build();
return Result.success(output);
}
}

// 监听返回
WorkManager.getInstance().getWorkInfoByIdLiveData(worker.getId())
.observe(lifecycleOwner, info -> {
if (info != null && info.getState().isFinished()) {
// 获取返回结果, 应该是3
int result = info.getOutputData().getInt(KEY_RESULT, 0));
}
});

一些需要注意的地方

  • WorkManager 虽然在设计的时候是为了在 App 没运行的时候也能运行 Worker, 但是目前从 Google Issue Tracker 上的信息来看, 以下几种情况杀掉后任务的存活情况是这样的:

    1. 从任务管理器(最近使用)关掉: 原生的 Android 上 Worker 仍然会运行, 但是在某些把这种操作当做强制停止的厂商一些中国厂商 的机型上, Worker 要等到下次打开 App 才会运行.
    2. 重启手机 (Worker 运行中的状态): 重启后 Worker 会继续运行.
    3. App 信息 -> 强制关闭: Worker 会再下次打开 App 的时候运行.
    4. 重启手机 (App 被强制关闭了): Worker 会再下次打开 App 的时候运行.

目前 Android 没有系统 Api 提供步数信息, 需要自己统计. 而系统能够提供的接口只有 Sensor 了.

方案对比

方案 1

如果为了兼容低版本, 可以从 Sensor.TYPE_ACCELEROMETER(加速度传感器) 开始. 这个传感器从 Android 1.5 (API 3) 就有了, 通过传感器的 x, y, z. 利用算法计算出相应步数. 特点是兼容性强, 缺点是高功耗, 需要进程常驻.

方案 2:

从 Android 4.4 (API 14) 开始, 系统加入了新的传感器: Sensor.TYPE_STEP_COUNTERSensor.TYPE_STEP_DETECTOR.

  • Sensor.TYPE_STEP_COUNTER

    1. 当步数变化时, 返回从开机到现在的总步数, 重启清零.
    2. 这个传感器就是为低功耗设计的, 如果想持续监听步数, 不要反注册
    3. 用来实现健身类 App 统计步数.
  • Sensor.TYPE_STEP_DETECTOR

    1. 走了一步就返回一步, 返回值只有1.
    2. 方便用来统计某一段时间内的步数.

那么为了统计一天的步数, 当然是使用 方案2 中的 Sensor.TYPE_STEP_COUNTER.

具体实现

  1. 注册传感器.

  2. 当手机步数变化时, SensorEventListener#onSensorChanged() 返回手机从开机到现在的总步数:

    • 记录中是否有今日步数?

    • 没有 -> 今日步数为0

    • 有 ->

        1. 如果传感器返回的步数 < 记录的今日步数, 那么意味着重启了手机, 则
                今日步数 = 今日步数 + 传感器返回的步数.


        2. 如果不是大于, 那么正常计步即可.


                 今日步数 = 今日步数 + (传感器返回的步数 - 记录的今日步数)

补充逻辑

因为每天第一次打开 App 时, 步数一定为0, 所以可以监听时间变化广播 Intent.ACTION_DATE_CHANGED , 每天打开一次 App 更新本日步数变化, 也可使用 WorkManager, 这样就算 App 没有开启也能更新.

已知问题

  1. 如果 SensorEventListener 没有收到回调(需要 App 没有被杀并且手机步数有变化), 那么今天的步数就会计入第二天.

距离 Google 发布 Lifecycle 组件已经有一段时间了, 特意尝鲜用了一下, 这里主要记录一下 ViewModel 组件的原理和功能.

ViewModel

用途

ViewModel 用来存储和管理 UI 相关的数据, 在 UI 重建时不需要重建其相应的 ViewModel, 也可以在不同的 Fragment 之间共享一个 ViewModel.

1
2
3
4
5
6
public abstract class ViewModel {
protected void onCleared() {

}
}

它本身就是一个抽象类, 可以实现 onCleared() 方法做一些清理工作

实现细节

获取 ViewModel 的方法很简单:

1
ViewModel viewModel = ViewModelProviders.of(context).get(ViewModel.class);

通过这个方法去追踪实现即可, 实际代码不多, 就贴个实现过程吧.

context 传入 FragmentFragmentActivity, 因为他们都可以添加 Fragment. 这里就以 FragmentActivity 为例来说明.

  1. 创建一个没有 view 的 HolderFragment, HolderFragment 中通过设置 setRetainInstance(true); 实现它在 Activity 重建时能够保存自己的实例
  2. HolderFragmentViewModelStores 会维护一个 HashMap<String, ViewModel> 用于记录其实例化过的 ViewModel 对象, 并在 HolderFragmentonDestroy() 回调中执行 ViewModel 的 onCleared() 方法

这里有一个关于生命周期的细节, 为了防止 Fragment 重复创建或者泄露, Google 对其做了一些保护:

1
private Map<Activity, HolderFragment> mNotCommittedActivityHolders = new HashMap<>();

add Fragment 的流程(以 FragmentActivity 为例):

  1. Application 注册 ActivityLifecycleCallbacks, 在 Activity onDestroy() 时从 mNotCommittedActivityHolders 中移除未 commit 的 HolderFragment.

    1
    2
    3
    4
    5
    6
    activity.getApplication().registerActivityLifecycleCallbacks(new EmptyActivityLifecycleCallbacks() {
    @Override
    public void onActivityDestroyed(Activity activity) {
    HolderFragment fragment = mNotCommittedActivityHolders.remove(activity);
    }
    });
  2. commit HolderFragment 到对应 Activity

    1
    2
    3
    4
    5
    private static HolderFragment createHolderFragment(FragmentManager fragmentManager) {
    HolderFragment holder = new HolderFragment();
    fragmentManager.beginTransaction().add(holder, HOLDER_TAG).commitAllowingStateLoss();
    return holder;
    }
  3. 在 HolderFragment 的 onCreate() 生命周期中从 mNotCommittedActivityHolders 中移除自己

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void holderFragmentCreated(Fragment holderFragment) {
    Fragment parentFragment = holderFragment.getParentFragment();
    if (parentFragment != null) {
    mNotCommittedFragmentHolders.remove(parentFragment);
    parentFragment.getFragmentManager().unregisterFragmentLifecycleCallbacks(
    mParentDestroyedCallback);
    } else {
    mNotCommittedActivityHolders.remove(holderFragment.getActivity());
    }
    }

这样可以防止重复调用导致多次创建 HolderFragment, 也可以在 Fragment 创建后移除不必要的备份, 更可以防止内存泄露.

App 之间回调是常有的事, 特别是一些提供第三方登录/第三方支付的 App, 更是需要提供调起, 登录/支付, 回调原 App 的功能. 在实现的过程中遇到一些问题, 所以记录一下.

接下来调起界面称为 CallActivity, 调起 App 称为 Call App , 被调起页面称为 PayActivity. 被调起 App 称为 Pay App.

常见方案: startActivityForResult

最常见的当然是 通过 startActivityForResult() 使用隐式 Intent 调起, 然后在 onActivityResult() 中捕捉回调并处理成功失败逻辑了. 这种方法大部分人都会, 说点遇到的问题吧:

  • 问题来了

    • 问题一:
      • 打开 Pay Activity 后可以切到后台, 再切回来, 这样做一方面安全性不够, 另一方面不符合支付工具的特性. 第三方应用调起支付应用后, 对用户的感觉不应该是完整的打开了一个应用, 而应该是仅仅启动了支付的一个功能, 切到后台后应该无法再切回该支付页面, 最近打开的应用页面也不该展示 Pay App.
      • AndroidManifest.xml 中加上 android:excludeFromRecents="true"
    • 问题二:
      • 支付成功后, 按 Home 键切到后台, 再切回 Call App, 这时候没有触发 onActivityResult()
      • 这是系统问题, 正常逻辑, 只能在 onResume() 里面查询后台是否成功.
    • 问题三:
      • 如果已经打开了 Pay App, 然后切到后台, 打开到 Call App, 调起 Pay Activity 后按返回键, 返回到 Pay App 的界面了.
      • 需要指定 PayActivity 的 luanchMode 为 singleInstance
  • 大坑来了

    坑就在于这个 singleInstance, 在 Android 5.0 上一切正常, 但是在 Android 4.4 及以下版本, Call Activity 调用 startActivityForResult() 后, 直接回调了 onActivityResult(), 然后才打开 Pay Activity.

    为此我记录了一下不同 luanchMode 对 Android 4.4 及以下版本回调的影响.

    正常回调 -> Y

    直接回调 -> N

       | Call Activity  | Standard | SingleTop | SingleTask | SingleInstance 
      :---: | :---:  | :---: | :---: | :---: | :---: 
      Pay Activity  |
      Standard | | Y | Y | Y | N 
      SingleTop | | Y | Y | Y | N 
      SingleTask | | N | N | N | N 
      SingleInstance | | N | N | N | N 
  • startActivityForResult 方案不可行

    对于 startActivityForResult() 来说, 想实现对 singleInstance 的回调是不可能了, 同时还有问题二也需要优化, 所以最好还是换个方案来执行.

透明中间页 + 广播方案

之前版本由于已经发布, 需要兼容旧版本, 同时为了优化问题二, 考虑在接入 sdk 中提供一个中间页面 Entry Activity. 另外对于 singleInstance 的问题, 考虑用广播来替代.

Call Activity -> Entry Activity -> Pay Activity

  • 各页面功能如下:

    Call Activity

    • 依旧执行 startActivityForResult 方法, sdk 内部直接调起 Pay Activity 改为调起透明 Entry Activity.

    • 在 onActivityResult 中处理回调逻辑.

      Entry Activity

    • 设置 theme 为透明, 同时取消进入和退出的动画. 设置 luanchMode 为 singleTop

    • 注册广播, 监听 Pay Activity 发来的支付成功的广播

    • 直接通过 startActivity 调起 PayActivity

    • 在第二次进入 onResume() 时判断是否收到了支付成功的广播, 否则当做支付失败处理.

    • 成功及支付都通过 setResult() 的方式回调

      Pay Activity

    • 支付成功使用广播通知.

Android 中单元测试并不常见, 这篇文章就我自己的知识范围来介绍:

JUnit

在学习 Java 时就知道这是一个用来给纯 Java 测试的工具, 在 Android 中一样使用, Android Studio 可以用快捷键 cmd + enter 为每个类自动创建测试类. 在测试类中, 一般使用 Assert 来断言每个条件的对错, 而 JUnit 的注解则为单元测试提供框架.

  • @Test

    定义测试单元(每个方法为一个单元用例), 接受参数有

    • expected 预期会抛出某个异常, 不抛出则报错

      1
      Class<? extends Throwable> expected() default None.class;
    • timeout 超时

      1
      long timeout() default 0L;
  • @Before

    每个 Test 方法执行之前都会调用, 可以做预处理操作, 必须修饰 public 方法

  • @After

    每个 Test 方法执行之后都会调用, 可以做清理操作

  • @BeforeClass

    由于连续测试可能需要共享一个变量, 或者每个测试单元执行前都需要很长时间的准备工作, 可以把这些准备工作移到 BeforeClass 中, 该注解必须注解于一个 public static void没有参数的方法.

  • @AfterClass

    用于 @BeforeClass 的清理工作

  • @Ignore

    用于执行测试的时候忽略某个 @Test 单元

  • @Rule

    定义 @Test 单元执行时的逻辑, org.junit.rules 包定义了一些常用的 Rule 规则, 但我们也可以自定义 Rule.

    1
    2
    3
    4
    5
    6
    7
    8
    public class RuleSample implements TestRule {

    @Override
    public Statement apply(Statement base, Description description) {
    base.evaluate();
    return null;
    }
    }

Mockito

Mock 即[模拟]的意思, 当某些类因为依赖太多等关系难以创造, 或者我们只需要一个类的对象时, Mock 便是一个很好的工具, 可以帮助我们隔离代码进行测试. 而 Mockito 便是一个 Android 常用的 Mock 工具类.

模拟对象

Mockito 支持多种模拟对象方法.

  • @Mock 直接注解对象. 注解对象需要初始化, 可以有四种初始化方法.

    • @Before 注解的初始化方法中使用MockitoAnnotations.initMocks(this);

    • 使用自带的 @Rule 初始化

      1
      2
      @Rule
      public MockitoRule mockitoRule = MockitoJUnit.rule();
    • 使用 @RunWith 注解测试类.

      1
      2
      3
      4
      @RunWith(MockitoJUnitRunner.class)
      public class Test {

      }
    • 使用 Mockito.mock() 初始化对象.

  • @InjectMock 创建一个实例, 其余用 @Mock@Spy 注解创建的 mock 对象将被注入到用该实例中.

设置桩

Mockito 支持以下方法设置桩

1
2
Mockito.doXXX().when(XXX)
Mockito.when(XXX).thenXXX()

举个例子, 如果希望 TextView 在调用 getText() 时返回特定的内容, 可以使用

1
2
Mockito.when(mTextView.getText()).thenReturn("123);
Mockito.doReturn("123").when(mTextView).getText();

桩可以设置多次, 最终只会返回最后一次设置的值.

验证

Mockito 支持以下方法来验证函数执行

1
2
public static <T> T verify(T mock)
public static <T> T verify(T mock, VerificationMode mode)

默认的 VerificationMode 即 times(1), 即方法执行了一次, sdk 也提供了几种默认的验证模式实现, 如 never() , atLeastOnce() 等.

参数匹配

为了模拟某些参数的输入, 可以匹配这些参数的输入, 并返回所需的值. ArgumentMatchers.any() 是一个比较常用的方法.

比如模拟 TextView 的 OnClickListener().

1
2
3
4
5
6
7
8
Mockito.doAnswer(new Answer() {
@Override
public Object answer(InvocationOnMock invocation) throws Throwable {
View.OnClickListener listener = invocation.getArgument(0);
listener.onClick(null);
return listener;
}
}).when(mTextView).setOnClickListener(ArgumentMatchers.any(View.OnClickListener.class));

@Spy

Mockito.spy() 返回的对象, 除非该方法已经有设置桩, 否则会调用该对象的真实方法. 可以用来改变对象特定方法的返回值, 而不改变对象本身. 注意 spy 不能 mock final 方法.

1
2
3
4
5
6
List list = new LinkedList();
List spy = Mockito.spy(list);
spy.add("one");
when(spy.size()).thenReturn(100);
spy.add("two");
System.out.println(spy.size());

JMockit

Mockito 的语法虽然比较简单易懂, 但它支持的功能还是不够多, 有一部分人是配合 PowerMock 一起使用, 但还有一个更强大的 Mock 工具值得使用.

模拟对象

注意 @RunWith 注解初始化测试类.

  • @Mocked 注解对象, 除了基本类型和数组对象, 其余所有都可以 mock, 会 mock 类中全部方法及其父类. 可以指定 stubOutClassInitialization 来决定 mock 对象时是否需要初始化静态变量, 某些 JNI 调用的静态变量在 mock 时初始化可能为 false, 这时候可以指定为 true 来跳过初始化.

  • @Injectable 仅 mock 指定的对象, 对于可以传入的对象, 使用 @Injectable 比 @Mock 更好

  • @Capturing mock 类及其子类, 也可以 mock 接口, 可以指定 maxInstances 来决定需要 mock 多少个对象.

常规使用

JMockit 用法是 录制 - 执行 - 验证 (record - replay - verify).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
public void testMethod() throws Exception {

//record
new Expectations(){{
//执行方法, 可以是构造函数
a.getName();
//指定返回
result = "123";
}};

//run test code

//verify
new Verifications(){{

}};
}

参数匹配

参数匹配有很多, 最宽松的是 anyXXX , 其次是 withXXX.
使用 null 时, 必须有一个 anyXXX 或者 withXXX .

MockUp API

JMockit 我觉得 MockUp Api 特别强大, 几乎可以 Mock 所有的方法, 静态方法, 构造函数, 私有函数都可以, 这也是这个工具强大的地方.

比如有如下测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Test {

public Test(String name) {

}

public static String getName() {

}

private String getAnswer() {

}
}

那么可以通过这样去 mock 对应的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
new MockUp<Test>() {

@Mock
public void $init(Invocation invocation, String name) {
Test test = invocation.getInvokedInstance();
}

@Mock
public String getName() {
return "Mocked Name";
}

@Mock
private String getAnswer() {
return "Mocked Answer";
}
}

一些 Tip

  • 如果只是某个方法需要某个 mock 过的变量, 可以在测试方法的入参中传入, 不需要写成全局变量.

    1
    2
    3
    4
    @Test
    public void test(@Mocked TextView textView) {

    }

本文主要列举一些常见的架构及自己的理解.

MVC

经典 MVC 架构, 是在 1979 年由 Trygve Reenskaug 在这篇论文中提出的, 这里附上一篇 winter 老师的翻译, 当时文章提到的关键词有 Model, View, ControllerEditor.

那时候并没有操作系统和消息循环(即不像现在的 Android ,不是事件驱动), 当时更多是面对鼠标, 键盘驱动这样的底层环境, 甚至鼠标的光标 UI 都要自己绘制.

原作者给出的关系图是这样的

  • Model

    Model 是计算机对现实世界建立的模型, 可以是数据存储, 也可以是数据对象.

  • Controller

    因为用户是接触鼠标和键盘, 所以输入是从 Controller 开始, Controller 根据指定的输入选择相应的 View 来显示, 即 Controller 与 View 是一对多的关系.

  • View

    View 是 model 的表现形式, 可以从一个或多个 model 获取想要的数据. View 向 model 获取数据, 也可以更新 model. View 不知道用户输入, 只是用来展示给用户.

  • Editor

    Controller 与 View 连接, 而某些 View 提供一种特殊的 Controller, 即 Editor, 它允许用户修改 View 表示的信息.

由于当年的开发环境与现在有很大不同, 所以我认为经典MVC架构已经不适用于现在的开发.

1996年, Mike(提出 MVP 架构)对当时的开发架构以 MVC 的角度给出了另一个依赖关系图:

此时用户输入已经从 Controller(mouse.onClick) 转移到 View (View.onClick) 上.

Android 中的 MVC

Android 并不是完全符合 MVC 架构, 各组件之间并不是能够完全解耦. 以下是我的理解:

  • View : xml布局, 即用户接触到的界面和控件.

  • Model: Java 对象, POJO 和 Java Bean, 还有一些数据中心, 如 Retrofit 的 Service.

  • Controller: Activity 或 Fragment. 因为 View 不能根据逻辑控制自己, 所以 Controller 会操作 View, 同时也会获取数据. 这些操作都放在 Controller 里面了.

MVP

随着计算机的发展, 操作系统对开发提供的帮助越来越多, Controller 所提供的桥梁功能, 已经逐渐被操作系统代替. 而 Mike 提出的 Presenter , 只是在 MVC 的基础上对 Controller 做了更详细的规定:

他在论文中提到 we refer to this kind(指应用程序全局且使用interactor, command以及selection概念的) of controller as a presenter, 所以 Presenter 也是一种 Controller. MVC 和 MVP 的依赖关系图是一样的.

Android 中的 MVP

Android 中的 MVP, Presenter 作为桥梁的功能更加明显, 但是也有一些变种类型, 比如 Google 的 MVP 模式, 添加了 Contract 作为合约类, 定义了 View 和 Presenter 之间的接口, 而 Model 则作为数据中心, 提供所有获取数据的接口, Prsenter 中只需要选择是使用测试的 Data Repo 还是 正式的 Data Repo 即可.

这里我习惯用另一种 MVP:

  • View: Activity 和 Xml, 作为页面的展示, Activity 控制 View 的逻辑.

  • Model : Java 对象. 部分共用的 Data Repo.

  • Presenter : 每个 View 对应一个 Presenter, Presenter 获取数据(Retrofit 或 DataBase).

  • Contract : 约定 View 与 Presenter 之间的接口.

按照 Google 的做法, MVP 中 Model 应该包含数据中心, 负责提供获取数据的接口. 这样确实方便切换和测试. 但是实际项目中, 我认为大部分接口都只调用一次, 并且是在特定的页面, 所以我没有这样做.

MVVM

由于现在大部分开发的模式是标记语言 + 程序语音的组合, 如 (Android 的 Java + xml), view 与其他部分解耦得更加彻底, 2005年微软架构师在这篇文章中提出了 MVVM 的概念, 最重要的一点便是 View 和 ViewModel 之间实现了双向绑定.

  • View : xml 和 Activity, 与用户交互.

  • Model : 数据中心.

  • ViewModel: 其实只是普通 Java 类, 因为有 binding 的关系, 可以控制 View.