如何优雅地构建易维护、可复用的 Android 业务流程

有必定实际 Android 项目开发经验的人,必定曾经在项目中处理过不少重复的业务流程。例如开发一个社交 App ,那么出于用户体验考虑,会须要容许匿名用户(不登陆的用户)能够浏览信息流的内容(或者只能浏览受限的内容),当用户想要进一步操做(例如点赞)时,提示用户须要登陆或者注册,用户完成这个流程才能够继续刚刚的操做。而若是用户须要进行更深刻的互动(例如评论,发布状态),则须要实名认证或者补充手机号这样的流程完成才能够继续操做。php

而上面列举的还只是比较简单的状况,流程之间还能够互相组合。例如:匿名用户点击了评论,那么须要连续作完:html

  1. 登陆/注册
  2. 实名认证

这两个流程才能够继续评论某条信息。另外 1 中,登陆流程还可能嵌套“忘记密码”或者“密码找回”这样的流程,也有可能由于服务端检测到用户异地登陆插入一个两步验证/手机号验证流程。java

须要解决的问题

(一) 流程的体验应当流畅android

根据本人使用市面上 App 的经验,处理业务流程按体验分类能够分为两类,一种是触发流程完成后,回到原页面,没有任何反应,用户须要再点一下刚才的按钮,或者从新操做一遍刚才触发流程的行为,才能进行原来想要的操做。另一种是,流程完成后,若是以前不知足的某些条件此时已经知足,那么自动帮用户继续刚刚被打断的操做。显然,后一种更符合用户的预期,若是咱们须要开发一个新的流程框架,那么这个问题须要被解决。服务器

(二) 流程须要支持嵌套网络

若是在进行一个流程的过程当中,某些条件不知足,须要触发一个新的流程,应当能够启动那个流程,完成操做,而且返回继续当前流程。数据结构

(三) 流程步骤间数据传递应当简单app

传统 Activity 之间数据传递是基于 Intent 的,因此数据类型须要支持 Parcelable 或者 Serializable ,而且须要以 key-value 的方式往 Intent 内填充,这是有必定局限性的。此外,流程步骤间有些数据是共享的,有些是独有的,如何方便地去读写这些数据?框架

有人可能会说,那能够把这些数据放到一个公共的空间,想要读写这些数据的 Activity 自行访问这些数据。可是若是真的这样,带来的新问题是:应用进程是可能在任意时刻销毁重建的,重建之后内存中保存的这些数据也消失了。若是不但愿看到这样,就须要考虑数据持久化,而持久化的数据也只是被这一次流程用到,什么时候应该销毁这些数据?持久化的数据须要考虑自身的生命周期的问题,这引入了额外的复杂度。且并无比使用 Intent 传递方便多少。异步

(四) 流程须要适应 Android 组件生命周期

前面说到了应用进程销毁重建的问题,因为不少操做触发流程之后,启动的流程页面是基于 Activity 实现的,因此完成流程回到的 Activity 实例颇有可能不是原来触发流程时的那个 Activity 实例,原来那个实例可能已经被销毁了,必须有合适的手段保证流程完成后,回到触发流程的页面能够正确恢复上下文。

(五) 流程须要能够简单复用

还有流程每每是能够复用的,例如登陆流程能够在应用的不少地方触发,因此触发后流程结束之后的跳转页面也都是不同的,不能够在流程结束的页面写死跳转的页面。

(六) 流程页面在完成后须要比较容易销毁

流程结束之后,流程每一个步骤页面能够简单地销毁,回到最初触发流程的界面。

(七) 流程进行中回退行为的处理

若是一个流程包含多个中间步骤,用户进行到中间某个步骤,按返回键时,行为应该如何定义?在大多数状况下,应该支持返回上一个步骤,可是在某些状况下,也应当支持直接返回到流程起始步骤。

方案一:基于 startActivityForResult

其实提及流程这个事情,咱们最容易想到的应该就是 Android 原生提供给咱们的 startActivityForResult 方法,以 Android 官网中的一个例子(从通信录中选择一个联系人)为例:

static final int PICK_CONTACT_REQUEST = 1;  // The request code
...
private void pickContact() {
    Intent pickContactIntent = new Intent(Intent.ACTION_PICK, Uri.parse("content://contacts"));
    pickContactIntent.setType(Phone.CONTENT_TYPE); // Show user only contacts w/ phone numbers
    startActivityForResult(pickContactIntent, PICK_CONTACT_REQUEST);
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    // Check which request we're responding to
    if (requestCode == PICK_CONTACT_REQUEST) {
        // Make sure the request was successful
        if (resultCode == RESULT_OK) {
            // The user picked a contact.
            // The Intent's data Uri identifies which contact was selected.

            // Do something with the contact here (bigger example below)
        }
    }
}
复制代码

在上面的例子中,当用户点击按钮(或者其余操做)时,pickContact 方法被触发,系统启动通信录,用户从通信录中选择联系人之后,回到原页面,继续处理接下来的逻辑。从通信录选择用户并返回结果 就能够被看做为一个流程。

不过上面的流程是属于比较简单的状况,由于流程逻辑只有一个页面,而有时候一个复杂流程可能包含多个页面:例如注册,包含手机号验证界面(接收验证码验证),设置昵称页面,设置密码页面。假设注册流程是从登陆界面启动的,那么使用 startActivityForResult 来实现注册流程的 Activity 任务栈的变化以下图所示:

上图的注册流程实现细节以下:

  1. 登陆界面经过 startActivityForResult 启动注册页面的第一个界面 ---- 验证手机号;
  2. 手机号验证成功后,验证手机号界面经过 startActivityForResult 启动设置昵称页面;
  3. 昵称检查合法后,昵称信息经过 onActivityResult 返回给验证手机号界面,验证手机号界面经过 startActivityForResult 启动设置密码界面,因为设置密码是最后一个流程,验证手机号界面把以前收集好的手机号信息,昵称信息都一并传递给密码界面,密码检查合法后,根据现有的手机号、昵称、密码发起注册;
  4. 注册成功后,服务器返回注册用户信息,设置密码界面经过 onActivityResult 把注册结果反馈给设置手机号界面;
  5. 注册成功,设置手机号界面结束本身,同时把注册成功信息经过 onActivityResult 反馈给流程发起者(本例中即登陆界面);

经过这个例子能够看出来,手机号验证界面 不只承担了在注册流程中验证手机号的功能,还承担了注册流程对外的接口的职责。也就是说,触发注册流程的任意位置,都不须要对注册流程的细节有任何了解,而只须要经过 startActivityForResultonActivityResult 与流程对外暴露的 Activity 交互便可,以下图:

上面的例子中可能有一点令您疑惑:为何每一个步骤须要返回到验证手机号页面,而后由验证手机号页面负责启动下个步骤呢?一方面,因为验证手机号是流程的第一个页面,它承担了流程调度者的身份,因此由它来进行步骤的分发,这样的好处是每一个步骤(除了第一步)之间是解耦和内聚的,每一个步骤只须要作好本身的事情而且经过 onActivityResult 返回数据便可,假如后续流程的步骤发生增删,维护起来比较简单;另外一方面,因为每一个步骤作完都返回,当最后一个步骤作完之后,以前流程的中间页面都不存在了,不须要手动去销毁作完的流程页,这样编码起来也比较方便。

可是这么作带来一个小小的反作用:若是在流程的中间步骤按返回键,就会回到流程的第一个步骤,而用户有时候是但愿能够回到上一个步骤。为了让用户能够在按返回键的时候返回上一个步骤,就必需要把每一个步骤的 Activity 压栈,可是这样作的话最后一步作完以后如何销毁流程相关的全部 Activity 又是一个问题。

为了解决流程相关 Activity 的销毁问题,须要对上面的图作一点修改,以下:

原先,每一个步骤作完本身的任务之后只须要结束本身并返回结果,修改后,每一个步骤作完本身的任务后不结束本身,也不返回结果,同时须要负责启动流程的下一个步骤(经过 startActivityForResult),当它的下一个步骤结束并返回它的结果的时候,这个步骤能在本身的 onActivityResult 里接住,它在onActivityResult里须要作的是把本身的结果和它的下一个步骤的结果合在一块儿,传递给它的上一个步骤,并结束本身。

经过这样,实现了用户按返回键所须要的行为,可是这种作法的缺点是形成了流程内步骤间的耦合,一方面是启动顺序之间的耦合,另外一方面因为须要同时携带它下个步骤的结果并返回形成的数据的耦合。

除此之外我还见过有人会单独使用一个栈,来保存流程中启动过的 Activity , 而后在流程结束后本身去手动依次销毁每一个 Activity。我不太喜欢这种方法,它相比上面的方法没有解决实质问题,并且须要额外维护一个数据结构,同时还要考虑生命周期,得不偿失。

最后总结一下前文, startActivityForResult 这个方法有着它本身的优点:

  1. 足够简单,原生支持。
  2. 能够处理流程返回结果,继续处理触发流程前的操做。
  3. 流程封装良好,可复用。
  4. 虽然引入了额外的 requestCode,可是在某种程度上保留了请求的上下文。

可是这个原生方案存在的问题也是显而易见的:

  1. 写法过于 Dirty,发起请求和处理结果的逻辑被分散在两处,不易维护。
  2. 页面中若是存在的多个请求,不一样流程回调都被杂糅在一个 onActivityResult 里,不易维护。
  3. 若是一个流程包含多个页面,代码编写会很是繁琐,显得力不从心。
  4. 流程步骤间数据共享基于 Intent,没有解决 问题(三)
  5. 流程页面的自动销毁和流程进行中回退行为存在矛盾,问题(六)问题(七) 没有很好地解决。

实际开发中,这几个问题都很是突出,影响开发效率,因此没法直接拿来使用。

方案二:EventBus 或者其余基于事件总线的解决方案

基于事件解耦也是一种比较优雅的解决方案,尤为是著名的 EventBus 框架了,它实现了很是经典的发布订阅模型,完成了出色的解耦:

我相信不少 Android 开发者都曾经很愉快地使用过这个框架……………………………………最后放弃了它,或者只在小范围使用它。好比说我,目前已经在项目中逐渐删除使用 EventBus 的代码,而且使用 RxJava 做为替代。

经过具体的代码一窥 EventBus 的基本用法:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    EventBus.getDefault().register(this);
    EventBus.getDefault().post(new MessageEvent("hello","world"));

}

@Subscribe(threadMode = ThreadMode.MainThread)
public void helloEventBus(MessageEvent message){
    mText.setText(message.name);
}

@Override
protected void onDestroy() {
    super.onDestroy();
    EventBus.getDefault().unregister(this);
}

class MessageEvent {
  public final String name;
  public final String password;
  public MessageEvent(String name, String password) {
    this.name = name;
    this.password = password;
  }
}
复制代码

那它有什么不足之处呢?首先,发起一个通常的异步任务,开发者指望在回调中获得的是 这个任务 的结果,而在 EventBus 的概念中,回调中传递的是“事件”(例子中的 MessageEvent)。这里稍稍有点不一样,理论上,异步任务的结果的数据类型能够就是事件的数据类型,这样两个概念就统一了,然而实际中仍是有不少场合没法这样处理, 举个例子:A Activity 和 B Activity 都须要请求一个网络接口,若是把网络请求的响应的对象类型直接做为事件类型提供给它们的 Subscriber,就会产生混乱,以下图。

图中,A Activity 和 B Activity 都发起同一个网络请求(可能参数不一样,例如查天气接口,一个是查北京的天气,另外一个是查上海的天气),那么他们的响应结果类是同样的,若是把这个响应结果直接做为事件类型提供给 EventBus 的回调,那么形成的结果就是两个 Activity 都收到两次消息。我把它称为 事件传播在空间上引发的混乱

解决的方案一般是封装一个事件,把 Response 做为这个事件携带的数据:

public class ResponseEvent {
    String sender;
    Response response;
}
复制代码

在把响应对象封装成事件以后,加入了一个 sender 字段,用来区分这个响应应该对应哪一个 Subscriber ,这样就解决了上述问题。

不只仅在空间上, 事件传播还能够在时间上引发混乱,想象一种状况,若是前后发起两个相同类型的请求,可是处理他们的回调是不一样的。若是用传统的设置回调的方法,只要给这两个请求设置两个回调就能够了,可是若是使用 EventBus ,因为他们的请求类型相同,因此他们数据返回类型也相同,若是直接把返回数据类型当成事件类型,那么在 EventBus 的事件处理回调中没法区分这两个请求(没法保证一先一后的两个请求必定也是一先一后返回)。解决的方案也相似上面的方案,只要把 sender 这个字段换成相似 timestamp 这样的字段就能够了。

归根结底,事件传播在空间和时间上引发混乱的深层次缘由是,把传统的“为每一个异步请求设置一个回调”这种模式,变成了“设置一个回调,用来响应某一种事件”这种模式。传统的方式是一个具体的请求和一个具体的回调之间是强关联,一个具体的回调服务于一个具体的请求,而 EventBus 把二者给解耦开了,回调和请求之间是弱关联,回调只和事件类型之间是强关联

除了上面的问题,事实上还有一个更严峻的问题,具体代码:

// File: ActivityA.java
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_a);

    EventBus.getDefault().register(this);

    findViewById(R.id.start).setOnClickListener(
        v -> startActivity(new Intent(this, ActivityB.class))
    )
}

@Subscribe(threadMode = ThreadMode.MainThread)
public void helloEventBus(MessageEvent message){
    mText.setText(message.name);
}

@Override
protected void onDestroy() {
    super.onDestroy();
    EventBus.getDefault().unregister(this);
}

........

// File: ActivityB.java
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_b);

    findViewById(R.id.btn).setOnClickListener(v -> {
        EventBus.getDefault().post(new MessageEvent("hello","world"));
        finish();
    })
}
复制代码

上述代码的意图主要是:在 ActivityA 点击按钮,启动 ActivtyB, ActivtyB 承载了一个业务流程,当 ActivityB 所承担的流程任务完成之后,点击它页面内的按钮,结束流程,把结果数据经过 EventBus 传递回 ActivityA ,同时结束本身,把用户带回 ActivityA。

理想状况下,这样是没有问题,可是若是在开启了 Android 系统中的 “开发者选项 - 不保留活动”选项之后,ActivityA 不会收到来自 ActivityB 的任何消息。“不保留活动”这个选项实际上是模拟了当系统内存不足的时候,会销毁 Activity 栈中用户不可见的 Activity 这一特性。这点在低端机中很是常见,常常玩着玩着手机,忽然接个电话,回来发现整个页面都从新加载了。那么缘由已经显而易见了:由于 ActivityA 在被系统销毁的时候执行了 onDestroy,从 EventBus 中移除了自身回调,所以没法接收到来自 ActivityB 的回调了。能不能不移除回调呢?固然是不能,由于这样会形成内存泄漏,更糟。

熟悉 EventBus 的朋友应该对 postSticky 这个方法不陌生,确实,在这种状况下,postSticky 这个方法可让事件多存活一段时间,直到它的消费者出现把它消费掉。可是这个方法也有一些反作用,使用postSticky发送的事件须要由 Subscriber 手动把事件移除,这就致使,若是事件有多个消费者,那写代码的时候就不知道应该在何时把事件移除,须要增长一个计数器或者别的什么手段,引入了额外的复杂度。postSticky的事件只是为了保证 Activity 重走生命周期后内部回调依然能够收到事件,却污染了全局的空间,这种作法我以为很是不优雅。

写到这里,这篇文章快成了 EventBus 批判文了,其实 EventBus 自己没问题,只是咱们使用者要考虑场景,不能滥用,仍是有些场合比较适用的,可是对于业务流程处理这个任务来讲,我并不认为这是一个很好的应用场景。

上述陈述中,不少例子我都使用了“异步任务”做为例子来阐述,主要是我认为其实在用户操做中咱们插入的业务流程也能够视为一种异步任务,反正最后结果都是异步返回给调用者的。因此我认为 EventBus 不适合异步任务的那些点,一样不适合业务流程。

其余的事件总线解决方案基本相似,Android 原生的 Broadcast 若是不考虑它的跨进程特性的话,在处理业务流程这件事情上基本能够认为是个低配版的 EventBus ,因此这里再也不赘述。

方案三:FLAG_ACTIVITY_CLEAR_TOP 或许是一种方案

因为考虑使用第三方的框架始终没法避开 Android 生命周期的问题(上一节 EventBus 案例中 Activity 的销毁与重建丢失上下文的例子)。咱们仍是倾向于从 Android 原生框架中寻找符合咱们要求的功能组件。这时我从 Intent Flags 中找到了 FLAG_ACTIVITY_CLEAR_TOP, 官方文档在这里, 我不打算照搬文档,可是想把其中一个例子翻译一下:

若是一个 Activity 任务栈有下列 Activity:A, B, C, D. 若是这时 D 调用 startActivity(), 而且做为参数的 Intent 最后解析为要启动 Activity B(这个 Intent 中包含 FLAG_ACTIVITY_CLEAR_TOP ), 那么 C 和 D 都会销毁,B 会接收到这个 Intent, 最后这个任务栈应该是这样:A, B。

这段只描述了现象,文档中还描述了更细节的数据流动,建议仔细阅读消化文档描述,我只把其中最重要的一块单独翻译一下:

上面的例子中的 B 会是下面两种结果之一

  1. onNewIntent 回调中接收到来自 D 传递过来的 Intent
  2. B 会销毁重建, 而重建的 Intent 就是由 D 传递过来的那个 Intent

若是 B 的 launchMode 被申明为 multiple(即standard) Intent Flags 中没有 FLAG_ACTIVITY_SINGLE_TOP, 那么就是上面的结果2。剩下的状况(launchMode 被申明为 multiple Intent Flags 中 FLAG_ACTIVITY_SINGLE_TOP),就是结果1.

上面的描述中,B 的结果1 就很适合咱们业务流程的封装,为何这么说呢,这里举一个例子。背景:一个社交 App, 首页信息流。假设全部 Activity 都在一个任务栈中,那么这个任务栈的变化以下图所示:

(1) 匿名用户浏览了一会,进行了一次点赞操做,此时触发登陆流程,登陆界面被弹出来; (2) 用户输完正确的用户名密码后(假设为老用户),服务器接收到登陆请求后,检测到风险,发起两步验证(须要短信验证),客户端弹出短信验证页面进行验证; (3) 用户输入正确的验证码,点击登陆,回到信息流页面,同时页面上点赞操做已经成功。

如何实现第3步中描述的现象呢? 只要在 Activity C 里面,登陆成功的逻辑里添加启动 Activity A 的逻辑,同时给这个启动 Activity A 的 Intent 同时加上 FLAG_ACTIVITY_CLEAR_TOP FLAG_ACTIVITY_SINGLE_TOP 两个 Intent Flag 便可(全部 Activity 的 launchMode 均为 standard), 代码以下:

Intent intent = new Intent(ActivityC.this, ActivityA.class);
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
// put your data here
// intent.putExtra("data", "result");
startActivity(intent);
复制代码

使用这种方法,优势以下:

  1. 能够把用户从新带回到触发流程以前页面;
  2. 有携带数据(本例中能够是用户的登陆信息)的回调;
  3. 在回调中能够帮助用户继续完成以前被打断的操做;
  4. 流程页面所有自动销毁,甚至 Activity C 自身的 finish() 方法都不须要调用;
  5. 即便打开不保留活动依然有效,Acitivity A 的 onNewIntent 回调会在 onCreate 以后被调用;
  6. 对流程进行一半时的页面回退支持良好;

看上去这种方法彷佛比 方案一 要好不少, 可是其实上面的例子仍是有点问题:最后一步 Activity C 显式启动了 Activity A。流程页不该该和触发流程的页面发生任何耦合,否则流程就没法复用,因此应该想一种机制,可让二者不耦合,同时又能够把流程完成后携带的数据传递给流程触发的地方。目前能想到比较合适的手段就是 方案一 中的 startActivityForResult了,具体作法是,Activity A 只和 Activity B 经过 startActivityForResultonActivityResult 进行交互,流程最后一个页面则经过上述的 onNewIntent 把流程结束相关数据带回流程第一个页面(Activity B),由 Activity B 经过 onActivityResult 把数据传递给流程触发者,具体逻辑以下图所示:

这样流程封装和复用的问题解决了,可是这个方案仍是存在一些缺点:

  1. startActivityForResult 同样,写法 Dirty,若是流程不少,维护较为不易;
  2. 即便是同一个流程,在同一个页面中也存在复用的状况,不增长新字段没法在 onNewIntent 里面区分;
  3. 问题(三) 没有获得解决,onNewIntent 数据传递也是基于 Intent 的, 也没有用于步骤间共享数据的措施,共享的数据可能须要从头传到尾;
  4. 步骤之间有轻微的耦合:每一个步骤须要负责启动它的下一个步骤;

其中缺点2解释一下,点赞会触发登陆,评论也会触发登陆,二者登陆成功都会返回信息流页面。不增长额外字段,onNewIntent 只是接收到了用户的登陆信息,并不知道刚刚进行的是点赞仍是评论。

这个方案和纯 startActivityForResult 的方案(方案一)有一种互补的感受,一个擅长流程页不支持回退的状况,另外一种擅长流程页支持回退的状况,并且它们都没有很好解决 问题(三) , 咱们须要进一步探索是否有更优方案。

方案四:利用新开辟的 Activity 栈来完成业务流程

因为咱们目前接手的项目中的流程页面,都是基于 Activity 实现的,那么天然而然就能想到应该让处理流程的 Activity 们更加内聚,若是流程相关 Activity 都是在一个独立的 Activity 任务栈中,那么当流程处理完之后,只要在拿到流程的最终结果之后销毁那个任务栈便可,简单又粗暴。

若是依然使用上面那个信息流登陆的例子的话,Activity 任务栈的变化应该以下图所示:

要实现图中的效果,那么须要考虑两个问题:

  1. 如何开启一个新的任务栈,把涉及流程的 Activity 都放到里面?
  2. 如何在流程结束之后销毁流程占用的任务栈,同时把流程结果返回到触发流程的页面?

问题1相对而言比较简单,咱们把流程相关的全部 Activity 显式设置 taskAffinity (例如 com.flowtest.flowA), 注意不要和 Application 的 packageName 相同,由于 Activity 默认的 taskAffinity 就是 packageName。启动流程的时候,在启动流程入口 Activity 的 Intent 中增长 FLAG_ACTIVITY_NEW_TASK 便可:

<!-- In AndroidManifest.xml -->
<activity android:name=".ActivityB" android:taskAffinity="com.flowtest.flowA"/>
复制代码
// In ActivityA.java
Intent intent = new Intent(ActivityA.this, ActivityB.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
复制代码

而流程中的其余 Activity 的启动方式不须要作任何修改,由于它们的 taskAffinity 与 流程入口 Activity 相同,因此它们会被自动置入同一个任务栈中。

问题2稍微复杂一点,据我所知,目前 Android 尚未提供在任务栈之间相互通讯的手段,那咱们只能回过头继续考虑 Activity 之间数据传递的方法。首先,出于流程复用性考虑, 流程依然仍是暴露 Activity B, 而流程触发者(Activity A) 经过 Activity B 以及startActivityForResultonActivityResult 两个方法与流程交互; 其次,流程内部的步骤要和 Activity B 的交互的话,有 onNewIntent 以及 onActivityResult 这两种回调的方法。

看上去这种思路比较有但愿,可是通过几回试验,我放弃了这种作法,缘由是一旦开辟一个新的任务栈来处理,手机上最近应用列表上,就会多一个App的栏位(多出来的那个表明流程任务栈),也就是说用户在作流程的时候若是按 Home 键切换出去,那他想回来的时候,按 最近应用列表,他会看到两个任务,他不知道回哪一个。即便流程完成, 最近应用列表 中还会保留着那个位置,后续依然会给用户形成困惑。另外,任务栈切换时的默认动画和 Activty 默认切换动画不一样(虽然能够修改为同样),会在使用过程当中感受有一丝怪异。

方案五:使用 Fragment 框架封装流程

到目前为止,上面各类方案中,相对能使用的方案,只有方案一和方案三。方案一中又存在一对矛盾,若是但愿流程内全部步骤都能优雅销毁,步骤之间耦合更松散,就无法保证回退行为;回退行为有保证之后,流程步骤的销毁就不够优雅了,步骤之间耦合也紧一些;方案三中,流程步骤销毁的问题和回退得以优雅解决,可是步骤间的耦合没有解决。咱们但愿一种可以一箭双鵰的方案,步骤之间耦合松散,回退优雅,销毁也容易。

仔细分析两种方案的优缺点,其实不可贵出结论:之因此仅靠 Activity 之间交互难以达成上述目标本质上是因为 Activity 任务栈没有开放给咱们足够的 API,咱们与任务栈能作的事情有限。看到这里其实就容易想到 Android 中,除了 Activity ,Fragment 也是拥有 Back Stack 的,若是咱们把流程页以 Fragment 封装,就能够在一个 Activity 内经过 Fragment 切换完成流程;因为 Activity 与 Fragment Back Stack 生命周期同在,Activity 就成了理想的保存 Fragment Back Stack 状态(流程状态)的理想场所;此外,只要调用 Activity 的 finish() 方法就能够清空 Fragment Back Stack!

仍然以登陆两步验证为例,通过 Fragment 改造之后,触发流程的点只会启动一个 Activity ,而且只和这个 Activity 交互,以下图所示:

Activity A 经过 startActivityForResult 启动 ActivityLogin,ActivityLogin 在内部经过 Fragment 把业务流程完成,finish 自身,而且把流程结果经过 onActivityResult 返回给 Activity A。流程包含的两个步骤被封装成两个 Fragment , 它们与宿主 Activity 的交互以下图所示:

  1. ActivityLogin 启动流程第一个页面 ---- 密码登陆,经过 push 方法(本例中的方法皆伪代码)把 Fragment A 展现到用户面前,用户登陆密码验证成功,经过 onLoginOk 方法回调 ActivityLogin,ActivityLogin 保存该步骤必要信息。

  2. ActivityLogin 启动流程第二个页面 ---- 两步验证,同时附带上个步骤的信息传递给 Fragment B,也是经过 push 方法,手机短信验证成功,经过 onValidataOk 方法回调 ActivityLogin, ActivityLogin 把这步的数据和以前步骤的数据打包,经过 onActivityResult 传递给流程触发点。

再回过头看开头,咱们对新的流程框架提出了7个待解决问题,再看本方案,咱们能够发现,除了 问题(三) 还存疑,其他的问题应该说都获得了妥善的解决。

正常状况下,添加 Fragment 是不带有动画的,没有像 Activity 切换那样的默认动画。为了可使 Fragment 的切换给用户的感受和 Activity 的体验一致,我建议把 Fragment 的切换动画设置成和 Activity 同样。首先,给 Activity 指定切换动画(不一样手机 ROM 的默认 Activity 切换动画不同,为了使 App 体验一致强烈推荐手动设置切换动画)。

以向左滑动进入、向右滑动推出的动画为例,styles.xml 中设置主题以下:

<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar"> <item name="android:windowAnimationStyle">@style/ActivityAnimation</item> <!-- Customize your theme here. --> ... </style>


<!-- Activity 进入、退出动画 -->
<style name="ActivityAnimation" parent="android:Animation.Activity"> <item name="android:activityOpenEnterAnimation">@anim/push_in_left</item> <item name="android:activityCloseEnterAnimation">@anim/push_in_right</item> <item name="android:activityCloseExitAnimation">@anim/push_out_right</item> <item name="android:activityOpenExitAnimation">@anim/push_out_left</item> </style>
复制代码

定义进场和退场动画,动画文件放在 res/anim 文件夹下:

<!-- file: push_in_left.xml -->
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate android:fromXDelta="100%p" android:toXDelta="0" android:duration="400"/>    
</set>

<!-- file: push_in_right.xml -->
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate android:fromXDelta="-25%p" android:toXDelta="0" android:duration="400"/>
</set>

<!-- file: push_out_right.xml -->
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate android:fromXDelta="0" android:toXDelta="100%p" android:duration="400"/>
</set>

<!-- file: push_out_left.xml -->
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate android:fromXDelta="0" android:toXDelta="-25%p" android:duration="400"/>
</set>
复制代码

因此,加上 Fragment 的切换动画之后,上面的 push 方法的实现以下:

protected void push(Fragment fragment, String tag) {
        List<Fragment> currentFragments = fragmentManager.getFragments();
        FragmentTransaction transaction = fragmentManager.beginTransaction();
        if (currentFragments.size() != 0) {
            // 流程中,第一个步骤的 Fragment 进场不须要动画,其他步骤须要
            transaction.setCustomAnimations(
                    R.anim.push_in_left,
                    R.anim.push_out_left,
                    R.anim.push_in_right,
                    R.anim.push_out_right
            );
        }
        transaction.add(R.id.fragment_container, fragment, tag);
        if (currentFragments.size() != 0) {
            // 从流程的第二个步骤的 Fragment 进场开始,须要同时隐藏上一个 Fragment,这样才能看到切换动画
            transaction
                    .hide(currentFragments.get(currentFragments.size() - 1))
                    .addToBackStack(tag);
        }
        transaction.commit();
    }
复制代码

每一个表明流程中一个具体步骤的 Fragment 的职责也是清晰的:收集信息,完成步骤,并把该步骤的结果返回给宿主 Activity。该步骤自己不负责启动下一个步骤,与其余步骤之间也是松耦合的,一个具体的例子以下:

public class PhoneRegisterFragment extends Fragment {

    PhoneValidateCallback mPhoneValidateCallback;

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_simple_content, container, false);
        Button button = view.findViewById(R.id.action);
        EditText input = view.findViewById(R.id.input);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if (mPhoneValidateCallback != null) {
                    mPhoneValidateCallback.onPhoneValidateOk(input.getText().toString());
                }
            }
        });
        return view;
    }

    public void setPhoneValidateCallback(PhoneValidateCallback phoneValidateCallback) {
        mPhoneValidateCallback = phoneValidateCallback;
    }

    public interface PhoneValidateCallback {
        void onPhoneValidateOk(String phoneNumber);
    }
}
复制代码

这时候,做为一系列流程步骤的宿主 Activity 的职责也明确了:

  1. 做为流程对外暴露的接口,对外数据交互(startActivityForResultonActivityResult
  2. 负责流程步骤的调度,决定步骤间调用的前后顺序
  3. 流程步骤间数据共享的通道

举一个例子,注册流程由3个步骤组成:验证手机号、设置昵称、设置密码,流程 Activity 以下所示:

public class RegisterActivity extends BaseActivity {

    String phoneNumber;
    String nickName;
    User mUser;

    PhoneRegisterFragment mPhoneRegisterFragment;
    NicknameCheckFragment mNicknameCheckFragment;
    PasswordSetFragment mPasswordSetFragment;


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        /** * 保证不管 Activity 不管在首次启动仍是销毁重建的状况下都能获取正确的 * Fragment 实例 */
        mPhoneRegisterFragment = findOrCreateFragment(PhoneRegisterFragment.class);
        mNicknameCheckFragment = findOrCreateFragment(NicknameCheckFragment.class);
        mPasswordSetFragment = findOrCreateFragment(PasswordSetFragment.class);

        // 若是是首次启动,把流程的第一个步骤表明的 Fragment 压栈
        if (savedInstanceState ==  null) {
            push(mPhoneRegisterFragment);
        }

        // 负责验证完手机号后启动设置昵称
        mPhoneRegisterFragment.setPhoneValidateCallback(new PhoneRegisterFragment.PhoneValidateCallback() {
            @Override
            public void onPhoneValidateOk(String phoneNumber) {
                RegisterActivity.this.phoneNumber = phoneNumber;

                push(mNicknameCheckFragment);
            }
        });

        // 设置完昵称后启动设置密码
        mNicknameCheckFragment.setNicknameCheckCallback(new NicknameCheckFragment.NicknameCheckCallback() {
            @Override
            public void onNicknameCheckOk(String nickname) {
                RegisterActivity.this.nickName = nickName;

                mPasswordSetFragment.setParams(phoneNumber, nickName);
                push(mPasswordSetFragment);
            }
        });

        // 设置完密码后,注册流程结束
        mPasswordSetFragment.setRegisterCallback(new PasswordSetFragment.PasswordSetCallback() {
            @Override
            public void onRegisterOk(User user) {
                mUser = user;
                Intent intent = new Intent();
                intent.putExtra("user", mUser);
                setResult(RESULT_OK, intent);
                finish();
            }
        });
    }
}
复制代码

其中 findOrCreateFragment 方法的实现以下:

public  <T extends Fragment> T findOrCreateFragment(@NonNull Class<T> fragmentClass) {
    String tag = fragmentClass.fragmentClass.getCanonicalName();
    FragmentManager fragmentManager = getSupportFragmentManager();
    T fragment = (T) fragmentManager.findFragmentByTag(tag);
    if (fragment == null) {
        try {
            fragment = fragmentClass.newInstance();
        } catch (InstantiationException e) {
                e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }
    return fragment;
}
复制代码

看到这里,您也许会对 findOrCreateFragment 这个方法实现有必定疑问,主要是针对使用 Class.newInstance 这个方法对 Fragment 进行实例化这行代码。一般来讲,Google 推荐在 Fragment 里本身实现一个 newInstance 方法来负责对 Fragment 的实例化,同时,Fragment 应该包含一个无参构造函数,Fragment 初始化的参数不该该以构造函数的参数的形式存在,而是应该经过 Fragment.setArguments 方法进行传递,符合上面要求的 newInstance 方法应该形如:

public static MyFragment newInstance(int someInt) {
    MyFragment myFragment = new MyFragment();

    Bundle args = new Bundle();
    args.putInt("someInt", someInt);
    myFragment.setArguments(args);

    return myFragment;
}
复制代码

由于使用 Fragment.setArguments 方法设置的参数,能够在 Activity 销毁重建时(重建过程也包含重建原来 Activity 管理的那些 Fragment),传递给那些被 Activity 恢复的 Fragment。

可是这边的代码为何要这么处理呢?首先,Activity 只要进入后台,就有可能在某个时刻被杀死,因此当咱们回到某个 Activity 的时候,咱们应该有意识:这个 Activity 多是刚刚离开前的那个 Activity,也有多是已经被杀死,可是从新被建立的新 Activity。若是是从新被建立的状况,那么以前 Activity 内的状态可能已经丢失了。也就是说对于给每一个流程步骤的 Fragment 设置的回调(setPhoneValidateCallbacksetNicknameCheckCallbacksetRegisterCallback)有可能已经无效了,由于 Activity 从新建立之后,内存中是一个新的对象,这个对象只经历了 onCreateonStartonResume 这些回调,若是给 Fragment 设置回调的调用不在这些生命周期函数里,那么这些状态就已经丢失了(能够经过 开发者选项里不保留活动 选项进行验证)。

可是有一个解决方法,就是把设置 Fragment 回调的调用写在 Activity 的 onCreate 函数里(由于不管是全新的 Activity 仍是重建的 Activity 都会走 onCreate 生命周期),如本例中的 onCreate 方法的写法。可是这就要求在 onCreate 函数内,须要获取全部 Fragment 的实例(不管是首次全新建立的 Fragment,仍是被恢复状况下,利用 FragmentManager 查找到的系统帮咱们自动恢复的那个 Fragment)。

可是流程中,很常见的状况是,某个步骤启动所须要的参数,依赖于上个步骤。若是使用 Google 推荐的那个最佳实践,很显然,咱们在初始化的时候须要准备好全部参数,这是不现实的,Activity 的 onCreate 函数里确定没有准备好靠后的步骤的 Fragment 初始化所须要的参数。

这里就产生了一个矛盾:一方面为了保证销毁重建状况下,流程继续可用,须要在 onCreate 期间得到全部 Fragment 实例;另外一方面,没法在 onCreate 期间准备好全部 Fragment 初始化所须要的参数,用来以 Google 最佳实践实例化 Fragment。

这里的解决方案就是上面的 findOrCreateFragment 方法,不彻底使用 Google 最佳实践。利用 Fragment 应该包含一个无参构造函数 这一点,经过反射,实例化 Fragment。

fragment = fragmentClass.newInstance();
复制代码

利用 Fragment 初始化的参数不该该以构造函数的参数存在,而是应该经过 Fragment.setArguments 方法进行传递 这一点,在每一个步骤结束的回调里启动下一个步骤的代码(本例中的 push 方法)以前,经过 Fragment.setArguments 方法传值。 PasswordSetFragment.setParams 的方法以下(底层就是 Fragment.setArguments 方法):

public void setParams(String phone, String nickname) {
        Bundle bundle = new Bundle();
        bundle.putString("phone", phone);
        bundle.putString("nickname", nickname);
        setArguments(bundle);
    }
复制代码

其实经过静态分析代码能够发现,调用 push 方法显示的 Fragment 实例,都是在 FragmentManger 中还没有存在的,也就是说,都是那些只被经过反射实例化之后,却尚未真正走过任何 Fragment 生命周期函数的 准新 Fragment。因此说,虽然咱们代码上好像和谷歌推荐的写法不同了,但本质上依然遵循谷歌推荐的最佳实践。

看到这里,这个经过 Fragment Back Stack 实现的流程框架的全部关键细节就都说完了。这个方案对比 方案一方案三 显然是更好的方案,由于它综合了这两个方案的优势。咱们来总结一下这个方案的优势:

  1. 流程步骤间是解耦的,每一个步骤职责清晰,只须要完成本身的事而且通知给宿主;
  2. 回退支持良好,用户体验流畅;
  3. 销毁流程只须要调用 Activity 的 finish 方法,很是轻量级;
  4. 只有一个 Activity 表明这个流程暴露给外部,封装良好并且易于复用;
  5. 流程步骤间数据的共享变得更简单

再回顾一下本文一开始提出的流程框架须要解决的 7 个问题,能够发现除了 问题(三) 没有彻底解决之外,其他问题应该都是获得了较为满意的解决。咱们来看一下 问题(三),这个问题的提出的前提是,流程的每一个步骤是基于 Activity 实现的,虽然使用基于 Fragment 的方案之后,Fragment 回调给 Activity 的数据再也不受 Bundle 支持格式的限制,可是从 Activity push 启动 Fragment 须要先调用 setArguments 方法,而这个方法支持的格式依然受 Bundle 的限制。若是咱们但愿 Android 在 Activity 销毁后重建时正确恢复 Fragment ,咱们只能接受这一点。

另外,虽然 Fragment 传递给 Activity 的数据格式不受限制了,考虑到 Activity 有可能销毁重建,为了保持 Activity 的状态,咱们仍是须要实现 Activity 的 onSaveInstanceState 方法和 onRestoreInstanceState 方法,而这两个方法依然是和 Bundle 打交道的:

@Override
    protected void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        outState.putString("phoneNumber", phoneNumber);
        outState.putString("nickName", nickName);
        outState.putSerializable("user", mUser);
    }

    @Override
    protected void onRestoreInstanceState(Bundle savedInstanceState) {
        super.onRestoreInstanceState(savedInstanceState);
        if (savedInstanceState == null) return;
        phoneNumber = savedInstanceState.getString("phoneNumber");
        nickName = savedInstanceState.getString("nickName");
        mUser = (User) savedInstanceState.getSerializable("user");
    }
复制代码

也就是说,若是咱们但愿咱们的 Activity/Fragment 能历经生命周期摧残,而始终以正确的姿态被系统恢复,那么咱们就要保证咱们的数据是可以被打包进 Bundle 的。咱们牺牲了编码上的便利性,换取代码执行的正确性。因此目前看来,**问题(三)**虽然没有被咱们解决或者绕过,可是其实本质上它的存在是能够被接受的。

总结

在探讨和比较了上面这么多方案之后,咱们终于找到相对而言最适合解决方案 ---- 方案(五):基于 Fragment 封装流程框架。可是这还不是终点,虽然在理论指标上,这个方案知足了咱们的需求,可是实际开发中,仍是有一些小问题等待被解决。好比:

  1. 流程对外暴露的接口是 startActivityForResult/onActivityResult,基于这个 API 进行开发,很难称得上是“优雅”;
  2. 发起流程的上下文应该如何保存,requestCode 能保存的信息量有限,尤为是在 ListView / RecyclerView 的场合下;
  3. 或许咱们应该借助一个框架来帮助咱们实现流程框架,而不是手写不少重复代码;

等等。

在下一篇分享中,我将继续介绍如何更优雅地去使用、封装流程框架,欢迎继续关注!

相关文章
相关标签/搜索