只有 QA 测出来的才叫 Bug(误
前段时间 Mentor 分给我一个 Bug,看了下详情描述差点打算提着刀去跟 QA 对线,“同时点击返回和保存按钮,提示保存失败,但相册中出现照片”。正常用户显然干不出来同时点返回和保存这种事情,不过既然 QA 提了,而且我这里也能必定复现,那就得修。
复现过程中发现,保存失败时相册中出现的是一个大小为 0 的文件,断点看了下是因为从 app 私有目录向用户相册复制文件时,执行到文件流操作的时候,由于点击了返回导致临时文件被删除从而写入失败。说实话这部分操作逻辑我个人是感觉怪怪的,不过这部分代码之前是别人写的,我入职前没多久才移交给我们部门,时间上也不允许重构。如果是我们部门最开始就写的话,应该会处理好这部分的吧。
所以这个问题实际上是因为删除和保存操作在时序上的不确定所导致的。最开始想的办法是,在最终保存之前二次检查文件是否存在,不过还是因为时序问题,导致有可能在通过检查之后、实际保存之前这段时间文件被删除。
既然最后会出现一个空文件,那么索性删掉就好了——虽然这么想着,但内部的文件目录操作风控 SDK 还是狠狠给了我一巴掌:“监测到高危行为”。如果直接通过 File
对象删除会被拦截,那我通过 MediaStore
不就行了。这次虽然没有被 SDK 拦截,但是想了想万一被国内定制的 Android 系统拦截了,那我就得背个 P0 了,所以这个方案也就毙掉了。
后来我又想了个办法,就是在点击返回的时候禁用掉保存按钮,等临时文件删除之后再启用;对应地,点击保存的时候禁用掉返回按钮,等保存结束之后再启用。不过和 Mentor 讨论的时候还是被毙掉了,因为在 Android 上,更改一个 View 的 isEnabled
属性会导致它在视觉效果上发生改变,这应该是目前 PM 所不能接受的。
在 Mentor 的建议和帮助下,最后采用的方案是,借助 RxJava 来把这两个时序上不确定的操作变为串行的。
对于保存操作,我们定义两个事件,一个是保存开始 SaveStart
,一个是保存结束 SaveEnd
;对于删除操作,我们定义一个删除事件 Delete
。这三个事件在时序上有如下关系:
- 由于保存操作可以多次进行,
Delete
一定位于最后一个SaveEnd
之后 - 一个
SaveStart
一定有一个与之匹配的SaveEnd
也就是说,SaveEnd
是作为触发后续操作的信号,自然我们会想到使用 RxJava 的 combineLatest
操作符,它会将各个 Observable
最后发射的数据,变换之后再发射出去。
对于三个事件,我们需要三个 PublishSubject
,它有点类似于 LiveData
,可以多次调用其 onNext()
方法来发射数据。PublishSubject
有一个 toSerialized()
方法来保证串行化,并且是线程安全的,刚好符合我们的需要。
这部分思路的大致代码如下:
sealed class OpreationEvent { class SaveStart(val path: String) : OperationEvent class SaveEnd(val path: String, val latest: Boolean) : OperationEvent class Delete(val path: String) : OperationEvent } val saveSubject = PublishSubject.create<SaveStart>() val saveEndSubject = PublishSubject.create<SaveEnd>() val deleteSubject = PublishSubject.create<Delete>() // 注意这里要 toSerialized() val saveStream = saveSubject.toSerialized() val saveEndStream = saveSuccessSubject.toSerialized() val deleteStream = deleteSubject.toSerialized() Observable.combineLatest(saveStream, saveEndStream) { save, saveEnd -> // Do something, return an instance of SaveEnd }
这时下游收到的一定就是 SaveEnd
事件了,使用 zipWith
操作符将它和 DeleteStream
结合起来就可以了,像这样:
Observable.combineLatest(saveStream, saveEndStream) { save, saveEnd -> // Do something, return an instance of SaveEnd } .zipWith(deleteStream) { _, _ -> deleteFile() }
注意到 SaveEnd
事件里我加了一个参数 latest
,用来判断是不是最后一个保存结束事件,所以我们还需要进行一下过滤,只在收到最后一个的时候来继续下游的删除操作:
Observable.combineLatest(saveStream, saveEndStream) { save, saveEnd -> // Do something, return an instance of SaveEnd } .filter { it.latest } .zipWith(deleteStream) { _, _ -> deleteFile() }
那么什么时候会有一个 latest
为 true
的 SaveEnd
事件呢?在 SaveStart
和 SaveEnd
里我都设置了 path
参数。由于 combineLatest
的特性,加上保存操作是耗时的,因此当且仅当 saveStream
和 saveEndStream
最后发射的数据中 path
相同时,才代表所有的保存操作都结束了,因此这个时候就可以安全触发删除了。于是有:
Observable.combineLatest(saveStream, saveEndStream) { save, saveEnd -> if (save.path == saveEnd.path) { SaveEnd(saveEnd.path, true) } else { saveEnd } } .filter { it.latest } .zipWith(deleteStream) { _, _ -> deleteFile() } .subscribe { /* No-op */ }
这样我们就实现了将返回和保存绝对串行化。
当然还有一种特殊情况,就是不点击保存,直接点击返回,那么这个需要稍微特殊处理一下:
var hasClickedSave = false if (hasClickedSave) { deleteSubject.onNext(Delete(path)) } else { deleteFile() }
增加一个标志位 hasClickedSave
,默认值为 false
。然后判断它的值来决定如何触发删除,在触发保存操作的时候将其置为 true
即可。
这应该是头一回正经研究 RxJava 操作符的用法吧,以前在学校最开始用 Java 和 RxJava 开发的时候,只会一股脑 flatMap
来把多个网络请求串在一起。工作之后确实会在实践中学到不少。