拥抱RxJava(四):动手做一个Full Rx的 注册界面

背景/Background

前阵子不久,Jake Wharton 在Devoxx 的演讲: The State of Managing State with RxJava 中提出了一个类似于Redux的 Full Rx 的App 结构。 如下图:

Redux

整个结构全部由RxJava控制 state。 和传统MVX结构类似,也是大致分UI层(View),中间层(Presenter/ViewModel/Controller或者我更喜欢叫Translator)和数据层(Model)。大致流程如下:

  1. Ui层(View)层将用户输入数据打包成UiEvent传递给中间层
  2. 中间层(Translator)将Event处理成对应的Action交给数据处理层。
  3. 处理结果打包成对应的Result交还给Translator
  4. Translator将数据结果打包成对应的UiModel交换给Ui做对应的Ui显示。

实现/Demo

我们先一步一步写个Demo看下这个结构的优缺点吧!
为了方便,我直接使用Android Studio提供的LoginActivity模板。
我们的目的是要做一个注册界面,为了简化只有用户名,密码。首先我们来定义Event:

public class AuthEvent {
    public final static class SignUpEvent extends AuthEvent {
        private final String username;
        private final String password;

        public SignUpEvent(String username, String password) {
            this.username = username;
            this.password = password;
        }
        //... getters
    }

}

这里SignUpEvent继承自AuthEvent是为了统一逻辑。这样我们可以在一整条stream里实现我们所有的逻辑。
我们在Ui层将这个Event打包(这里我使用RxBinding):

Observable<SignUpEvent> click = RxView.clicks(mEmailSignInButton)
        .map(ignore -> new SignUpEvent(mEmailView.getText().toString(),
                mPasswordView.getText().toString()));

这样我们每次点击按钮就会发射一个SignUpEvent出来。

再来我们定义我们的UiModel,我们首先要想好,我们的Ui到底有几种状态,我们将各种状态提前定义。我大致觉得我们需要四种状态:

  1. idle 初始状态,就是用户第一次进入的状态
  2. inProcess 状态,也就Ui界面等待注册是否成功的状态
  3. success 状态,注册成功 进行下一步操作
  4. fail 状态,注册失败,返回失败信息。

根据这四种状态,我们来定义UiModel:


public class AuthUiModel {
    private final boolean inProcess;
    private final boolean usrValidate;
    private final boolean pwdValidate;
    private final boolean success;
    private final String errorMessage;

    private AuthUiModel(boolean inProcess, boolean usrValidate, boolean pwdValidate, boolean success, String errorMessage) {
        this.inProcess = inProcess;
        this.usrValidate = usrValidate;
        this.pwdValidate = pwdValidate;
        this.success = success;
        this.errorMessage = errorMessage;
    }

    public static AuthUiModel idle() {
        return new AuthUiModel(false, true, true, false, "");
    }

    public static AuthUiModel inProcess() {
        return new AuthUiModel(true, true, true, false, "");
    }

    public static AuthUiModel success() {
        return new AuthUiModel(false, true, true, true, "");
    }

    public static AuthUiModel fail(boolean username, boolean password, String msg) {
        return new AuthUiModel(false, username, password, false, msg);
    }
    //... getters
}

再来是Model层,我们这里用一个简单的AuthManager来管理,解耦出来后这里可以替换成任意你喜欢的注册方式:

public class AuthManager {
    private SignUpResult result;
    private Observable<SignUpResult> observable = Observable.fromCallable(() -> result)
            //延迟2s发送结果,模拟网络请求延迟
            .delay(2000, TimeUnit.MILLISECONDS);

    public Observable<AuthResult.SignUpResult> signUp(SignUpAction action) {
        //检查用户名是否合法
        if (TextUtils.isEmpty(action.getUsername()) || !action.getUsername().contains("@")) {
            result = SignUpResult.FAIL_USERNAME;
        }
        //检查密码合法
        else if (TextUtils.isEmpty(action.getPassword()) || action.getPassword().length() < 9) {
            result = SignUpResult.FAIL_PASSWORD;
        } else {
            //检查结束,返回注册成功的信息
            // TODO:  createUser
            result = SignUpResult.SUCCESS;
        }
        return observable;
    }
}

这里SignUpAction里定义了我们注册所有需要的信息,代码和SignUpEvent几乎雷同。但是分离的好处是可以对数据进行在处理或者合并打包等等。

Ui和Model都准备好了,我们开始我们的Translator部分。 Translator部分主要又ObservableTransformer组成。 将各个部件组装,具体如下:

public final ObservableTransformer<SignUpEvent, AuthUiModel> signUp
        //上游是UiEvent,封装成对应的Action
        = observable -> observable.map(event -> new SignUpAction(event.getUsername(), event.getPassword()))
        //使用FlatMap转向,进行注册
        .flatMap(action -> authManager.signUp(action)
                //扫描结果
                .map(signUpResult -> {
                    if (signUpResult == SignUpResult.FAIL_USERNAME) {
                        return AuthUiModel.fail(false, true, "Username error");
                    }
                    if (signUpResult == SignUpResult.FAIL_PASSWORD) {
                        return AuthUiModel.fail(true, false, "Password error");
                    }
                    if (signUpResult == SignUpResult.SUCCESS) {
                        return AuthUiModel.success();
                    }
                    //TODO Handle error
                    throw new IllegalArgumentException("Unknown Result");
                })
                //设置初始状态为loading。
                .startWith(AuthUiModel.inProcess())
                //设置错误状态为error,防止触发onError() 造成断流
                .onErrorReturn(error -> AuthUiModel.fail(true, true, error.getMessage())));

这样我们在Activity里 将各个部分通过Translator组装:

disposables.add(click.compose(translator.signUp)
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe(authUiModel -> {
            //载入进度条
            mProgressView.setVisibility(authUiModel.isInProcess() ? View.VISIBLE : View.GONE);
            //判断用户名/密码是否合法
            if (!authUiModel.isPwdValidate()) {
                mPasswordView.setError(authUiModel.getErrorMessage());
            } else {
                mPasswordView.setError(null);
            }
            if (!authUiModel.isUsrValidate()) {
                mEmailView.setError(authUiModel.getErrorMessage());
            } else {
                mEmailView.setError(null);
            }
            //是否成功
            if (authUiModel.isSuccess()) {
                Toast.makeText(this, "CreateUser SuccessFull", Toast.LENGTH_SHORT)
                        .show();
            }
        }));

很明显的看到,在Activity中 只有Ui相关的处理,而中间的逻辑通过translator解耦出来,对Activity不可见。

问题/Issues

但是,问题来了。这里些许Bug.由于我们使用Transformer. 每次转屏的时候会通过RxView来生成新的Observable.这样我们的translator并没有复用,还是绑定在了生命周期上。那么如何解决?

我们设想一下,如果中间的Translator可以随时接受下游的订阅而且无论下游是否有订阅,他都可以一直运行,这样不就在下游彻底解耦了吗?这种特性的Observable我在上一篇文章中说到是ConnectableObservable。这里我们使用Replay(1)。这样我们就每次重新订阅,也会获得最近的一次UiModel,再也不用担心转屏/内存重启。

下游解决了,那上游呢?如果上游每次调用这个Transformer,每次还是一个新的Observable啊。理想的情况应该是我们有一个中间人,他不断接受Ui层传过来的UiEvent然后交给我们Transformer, 这样我们就能一直复用我们的Transformer。也就是他既作为一个Observer订阅上游UiEvent又作为一个Observable,给下游传递数据。那么答案呼之欲出,我们需要一个Subject作为中间人。
改善后的Translator代码如下:

public class AuthTranslator {
    private AuthManager authManager;
    private Subject<SignUpEvent> middle = PublishSubject.create();
    private Observable<AuthUiModel> authUiModelObservable
            = middle.map(event -> new SignUpAction(event.getUsername(), event.getPassword()))
            //使用FlatMap转向,进行注册
            .flatMap(action -> authManager.signUp(action)
                    //扫描结果
                    .map(signUpResult -> {
                        if (signUpResult == SignUpResult.FAIL_USERNAME) {
                            return AuthUiModel.fail(false, true, "Username error");
                        }
                        if (signUpResult == SignUpResult.FAIL_PASSWORD) {
                            return AuthUiModel.fail(true, false, "Password error");
                        }
                        if (signUpResult == SignUpResult.SUCCESS) {
                            return AuthUiModel.success();
                        }
                        //TODO Handle error
                        throw new IllegalArgumentException("Unknown Result");
                    })
                    //设置初始状态为loading。
                    .startWith(AuthUiModel.inProcess())
                    //设置错误状态为error,防止触发onError() 造成断流
                    .onErrorReturn(error -> AuthUiModel.fail(true, true, error.getMessage())))
            .replay(1)
            .autoConnect();

    public final ObservableTransformer<SignUpEvent, AuthUiModel> signUp
            //上游是UiEvent,封装成对应的Action
            = observable -> {
        //中间人切换监听
        observable.subscribe(middle);
        return authUiModelObservable;
    };

    public AuthTranslator(AuthManager authManager) {
        this.authManager = authManager;
    }
}

这样我们刚才说的两个Bug就解决了。而且即使我们在请求中转屏,也毫无问题。

总结

实践一下这个结构确实有很多优点。

  1. 将一整条state stream解耦分成几块,但又保持了一整条的结构。
  2. 相比传统MVX模式,多次控制翻转(Ioc),解耦更彻底
  3. 由于RxJava强大的操作符群。可以实现很多意想不到的功能

缺点也蛮明显:

  1. 我个人对这个架构理解也不是特别深入,中间的middle部分虽然用Subject 但是确实有其不稳定性,比如onError/onComplete会停止这个Subject造成断流
  2. 由于解耦彻底,造成需要很多辅助类,茫茫多的boilerplate。 不过这个在kotlin上有很好的发挥,sealed class,when 等语法几乎是为其量身定做。
  3. 难,真的难。比传统MVP,甚至MVVM�需要更清晰,更合理的设计。不提前想好use case就开始写几乎是不可能的。而且RxJava如果不熟悉,调试起来确实很难。经常不能定位到代码。最好做单元测试各个模块。

最后附上这个Demo 的GitHub Repo: RxAuthDemo

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容