MVP 中试用面向接口编程优化代码

问题出现的背景

当我们需要规定一类抽象行为的时候,是使用基类继承的方式还是组合的方式呢?
在使用 MVP 架构进行编码时,我碰到了下面的这个情况用以探讨并记录。

循序渐进,抽象出行为至协议的过程

假设一个 app 每次启动后都需要登录。

它最开始的登录方式只支持:密码登录

MVP 架构中, P 用于执行业务逻辑,并驱动 V 层作相应显示。

就上面的例子而言,每次登录包含的业务逻辑,P 层需要做下面几件事:

  • 检查更新
  • 检查当前网络的安全性
  • 登录后保存当前用户名

V 层提供了展示的接口

  • 展示网络无效的弹窗
  • 展示检查更新的结果


业务拓展,增加需求

他的登录方式拓展到了有三种:

三种登录方式.png
  • 密码登录
  • 生物识别登录(指纹/面容 ID)
  • 九宫格绘制图形解锁登录

三种登录方式都需要执行密码登录中的业务逻辑。
我们在设计的过程中需要把这个业务逻辑抽象出来。

传统的做法自然而然会想到使用基类,但是问题来了,由于我们使用了 MVP 的架构模式,业务逻辑主要分布在 P 层。而业务逻辑的过程中,势必需要驱动 controller(V 层)的变化,如果使用基类,那么在基类中又会耦合 V 的代码。

我们其实需要做的事是,希望有一个类,提供业务逻辑相关的行为供 P 调用,只管业务逻辑的事。至于页面的变化和驱动,仍交由各个 具体的 P 类自己完成。

那么你肯定会问,如果这样的话,让 P 的基类具备这些功能供子类调用就好了啊?
可是如果是这样,你觉得 这个类和 P 还是父子关系吗?

最好的方法,就是需要引入协议拓展 —— Swift 中的 protocol extension

协议拓展

//
//  LoginPresenterProtocol.swift
//  OSSApp
//
//  Created by Chen Defore on 2019/7/30.
//  Copyright © 2019 IGG. All rights reserved.
//

import UIKit

protocol LoginPresenterProtocol: class {
    func checkUpdate(onSuccess: @escaping CallbackWithNoParams)
    func checkNetworkPermission(onSuceess: @escaping CallbackWithNoParams, onFailed: @escaping CallbackWithNoParams)
    func handleLoginSuccess(with successInfo: LoginSuccessModel, onComplete: @escaping CallbackWithNoParams)
}

extension LoginPresenterProtocol {
    // MARK:【检查更新】
    func checkUpdate(onSuccess: @escaping CallbackWithNoParams) {
        // 使用静默检查,不添加 Loading 动画
        AppUpdateManager.shared.checkIfNeedUpdateAppSilently { [weak self] (checkResult) in
            onSuccess()
            self?.handleCheckUpdateResult(checkResult)
        }
    }
    
    // MARK:【处理检查更新的弹窗】
    private func handleCheckUpdateResult(_ result: CheckUpdateAppResult) {
        let model = GeneralAlertDialogViewModel(content: result.updateLog, actionButtonTitle: myLocal("update_now"))
        model.isContentScrollable = true
        model.contentViewMinHeight = 230
        
        var dialog: GeneralAlertDialogView?
        switch result.updateAppType {
        case .forcedUpdate:
            model.cancelButtonTitle = myLocal("exit_app")
            model.isTapBackgroundCloseEnabled = false
            dialog = GeneralAlertDialogView(model: model, onClose: {
                exit(0) // 左边按键是 退出应用
            }, onPressActionButton: {
                guard let url = result.updateURL else { return }
                UIApplication.shared.open(url, options: [:], completionHandler: { (_) in
                    exit(0)
                })
            })
        case .unforcedUpdate:
            model.cancelButtonTitle = myLocal("update_later")
            dialog = GeneralAlertDialogView(model: model, onClose: nil, onPressActionButton: {
                guard let url = result.updateURL else { return }
                UIApplication.shared.open(url, options: [:], completionHandler: { (_) in
                    exit(0)
                })
            })
        case .noUpdate:
            break
        }
        
        guard let _ = dialog else { return }
        DispatchQueue.main.async {
            UIApplication.shared.keyWindow?.addSubview(dialog!)
        }
    }
    
    // MARK:【检查网络权限】
    func checkNetworkPermission(onSuceess: @escaping CallbackWithNoParams,
                                onFailed: @escaping CallbackWithNoParams) {
        
        print("检查权限")
        IGGTransaction.shared.checkNetworkPermission { (result) in
            switch result {
            case .success(_):
                onSuceess()
            case .failure(let failure):
                if let code = failure.errorCode,
                    let errorType = IGGErrorType(rawValue: code),
                    errorType == .withoutPermission {
                    
                    // 提示用户无权限
                    onFailed()
                } else {
                    onFailed()// TODO 表示的是请求失败的场景 网络异常
                }
            }
        }
    }
    
    // MARK:【处理登录成功】
    func handleLoginSuccess(with successInfo: LoginSuccessModel, onComplete: @escaping CallbackWithNoParams) {
        // 与服务端约定好的接口
        UserInformation.shared.sessionID = successInfo.sessionID
        UserInformation.shared.realName = successInfo.realName
        UserInformation.shared.avatarURL = successInfo.avatarURL
        LanguageHelper.syncLanguage(from: successInfo.language)
        
        cleanCacheIfUserIDChanged(id: successInfo.userID) {
            UserInformation.shared.userID = successInfo.userID
            onComplete()
        }
    }
    
    // 如果用户 ID 发生了变化,为了避免新用户还能访问上一个用户的信息,需要将所有的缓存数据清空
    private func cleanCacheIfUserIDChanged(id: String?, onSuccess: @escaping CallbackWithNoParams) {
        guard let currentUserID = UserInformation.shared.userID,
            let newUserID = id, currentUserID != newUserID else {
                
                onSuccess()
                return
        }
        
        print("User ID 改变了!")
        // 用户变更,则生物识别失效
        LocalAuthenticationLoginCredential.shared.removeCredential()
        IGGCacheCenter.shared.cleanAllCache {
            // 清空缓存时,必须要同时清空单例中的数据,否则其中保存的数据在同步时仍然会再写入缓存
            HistoricalRecordManager.shared.removeAllRecords()
            OpenedFunctionRecordManager.shared.removeAllRecords()
            
            onSuccess()
        }
    }
}

具体的 P,生物识别登录

class LocalAuthenticationLoginPresenter: BasePresenter, LoginPresenterProtocol {
    private weak var controller: LocalAuthenticationLoginViewController?
    private var enterForegroundNotificationToken: NotificationToken?
    private var hasCheckUpdate: Bool = false
    
    init(controller: LocalAuthenticationLoginViewController) {
        self.controller = controller
        super.init()
        enterForegroundNotificationToken = NotificationCenter.default.igg.observe(name: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] (_) in
            
            self?.checkNetworkPermission() // 每次进入前台,都检测权限
        }
    }
    
    // MARK: - 【登录操作】
    func dologin() {
        // 授权
        LocalAuthenticationHelper.authenticate { [weak self] (isSuccess, error) in
            if isSuccess {
                let currentTimestamp = Int(ServiceSignatureHelper.generateTimestamp()) ?? 0
                let expiredTime = LocalAuthenticationLoginCredential.shared.accessTokenExpiredTime
                
                if currentTimestamp > expiredTime { // 超过了保质期
                    self?.refreshToken()
                } else {
                    self?.requestSessionID()
                }
            } else {
                let errorMessage = LocalAuthenticationHelper.checkUnavailableErrorInDetail(evaluateError: error!)
                self?.controller?.showAlert(with: errorMessage)
            }
        }
    }
    
    private func requestSessionID() {
        let accessToken = LocalAuthenticationLoginCredential.shared.accessToken ?? ""
        
        IGGToast.shared.showLoading()
        IGGTransaction.shared.localAuthenticationLogin(accessToken: accessToken) { [weak self] (result) in
            IGGToast.shared.dismiss()
            switch result {
            case .success(let loginSuccessModel):
                self?.handleLoginSuccess(with: loginSuccessModel)
            case .failure(let error):
                if let errorCode = error.errorCode,
                    let errorType = IGGErrorType(rawValue: errorCode),
                    errorType == .expiredToken {
                    
                    // 如果上面判断过期的步骤不准确,服务端会返回一个 token 过期的信息,这时候去发送刷新 token 的请求
                    self?.refreshToken()
                    return
                }
                
                let code = error.errorCode ?? ""
                let message = error.errorMessage ?? "\(myLocal("unknow_error")) \(code)"
                IGGToast.shared.showTip(message: message)
            }
        }
    }
    
    // Token 过期那么去刷新 token,
    func refreshToken() {
        let refreshToken = LocalAuthenticationLoginCredential.shared.refreshToken ?? ""
        print("刷新 token,刷新前的 token = \(refreshToken)")
        IGGToast.shared.showLoading()
        IGGTransaction.shared.refreshLocalAuthenticationLogonToken(refreshToken: refreshToken) { [weak self] (result) in
            IGGToast.shared.dismiss()
            switch result {
            case .success(let refreshedLoginModel):
                LocalAuthenticationLoginCredential.shared.saveCredential(from: refreshedLoginModel)
                print("刷新 token,刷新后的 token = \(refreshedLoginModel.refreshToken ?? "")")
                self?.requestSessionID()
            case .failure(let error):
                if let codeString = error.errorCode, let code = Int(codeString), code < 0 {
                    print("表示是 NSURLErrorDomain 这类本地网络错误")
                    let code = error.errorCode ?? ""
                    let message = error.errorMessage ?? "\(myLocal("unknow_error")) \(code)"
                    IGGToast.shared.showTip(message: message)
                    return
                }
                
                self?.handleRefreshTokenInvalid()
            }
        }
    }
    
    // 极端情况, refreshToken 无效的处理
    private func handleRefreshTokenInvalid() {
        /* 其他错误是服务端返回的错误信息,表示刷新 token 失败,此时需要将 生物识别凭证清空,
         提示用户生物识别失效提示,并进入密码登录页
         */
        LocalAuthenticationLoginCredential.shared.removeCredential()
        controller?.changeToPasswordLogin()
        IGGToast.shared.showTip(message: myLocal("invalid_refresh_token"), duration: 3) // 提示时间长一些,避免一闪而过
    }
    
    // MARK:【检查网络权限】
    func checkNetworkPermission() {
        IGGToast.shared.showLoading()
        checkNetworkPermission(onSuceess: { [weak self] in
            IGGToast.shared.dismiss()
            self?.controller?.dismissInvalidNetworkDialog()
            self?.checkUpdate()
        }) { [weak self] in
            IGGToast.shared.dismiss()
            self?.controller?.showInvalidNetworkDialog()
        }
    }
    
    // MARK:【处理登录成功】
    func handleLoginSuccess(with successInfo: LoginSuccessModel) {
        handleLoginSuccess(with: successInfo) { [weak self] in
            self?.addCookiesManually()
            self?.controller?.dismiss(animated: false, completion: nil)
            self?.controller?.enterMainPageOnLoginSucess()
        }
    }
    
    /* 因生物识别登录的步骤,没有密码登录 WKWebView 浏览器自己注入 Cookies 的操作(网页发送了请求后 response header 中 set-cookies 的操作)
     因此在这里需要手动注入 cookies,否则后续网页的相关请求都不带 cookies,造成会话失效
     
     ios 10 和 ios 11 的注入方式不尽相同
     */
    private func addCookiesManually() {
        if #available(iOS 11.0, *) {
            let cookie = HTTPCookie(properties: [
                .domain: "support.igg.com",
                .path: "/",
                .name: "__USER__",
                .value: "\(UserInformation.shared.sessionID ?? "")",
                .secure: "TRUE",
                .expires: NSDate(timeIntervalSinceNow: 3600)
                ])!
            
            WebViewManager.shared.configuration.websiteDataStore.httpCookieStore.setCookie(cookie,
                                                                                           completionHandler: nil)
        } else {
            let sessionID = UserInformation.shared.sessionID ?? ""
            let cookieScript = WKUserScript(source: "document.cookie = '__USER__=\(sessionID);path=/;secure=true'",
                injectionTime: .atDocumentStart,
                forMainFrameOnly: false)
            
            WebViewManager.shared.configuration.userContentController.addUserScript(cookieScript)
        }
    }
    
    // MARK:【检查更新】
    func checkUpdate() {
        guard hasCheckUpdate == false else { return }
        checkUpdate { [weak self] in
            self?.hasCheckUpdate = true
        }
    }
}

Controller 层的协议拓展

// MARK: -【登录页面 View 协议】
protocol LoginViewControllerProtocol: class {
    var invalidNetworkDialog: GeneralAlertDialogView? { get set }
    
    func showInvalidNetworkDialog()
    func showInvalidSessionDialog()
    func dismissInvalidNetworkDialog()
    func enterMainPageOnLoginSucess()
}

extension LoginViewControllerProtocol {
    func initInvalidNetworkDialog() -> GeneralAlertDialogView {
        let model = GeneralAlertDialogViewModel(content: myLocal("invalid_network"), actionButtonTitle: myLocal("go_to_myigg"))
        model.cancelButtonTitle = myLocal("exit_app")
        model.needHeader = false
        model.isTapBackgroundCloseEnabled = false
        model.contentViewMinHeight = 109
        let dialog = GeneralAlertDialogView(model: model, onClose: {
            print("退出应用")
            exit(0)
        }) {
            if let url = MyIGGSchemeURL.igg.obtainValidURL() {
                // 如果支持,那么打开 MyIGG
                UIApplication.shared.open(url, options: [:], completionHandler: nil)
            } else {
                // 不支持,那么打开 MyIGG 的下载地址
                guard let MyIGGDownloadURL = MyIGGDownlowdURL.igg.obtainValidURL() else { return }
                UIApplication.shared.open(MyIGGDownloadURL, options: [:], completionHandler: nil)
            }
        }
        
        return dialog
    }
    
    func showInvalidNetworkDialog() {
        guard let dialog = invalidNetworkDialog else { return }
        UIApplication.shared.keyWindow?.addSubview(dialog)
    }
    
    func showInvalidSessionDialog() {
        let model = GeneralAlertDialogViewModel(content: myLocal("invalid_session"), actionButtonTitle: myLocal("confirm"))
        let dialog = GeneralAlertDialogView(model: model, onClose: nil) {
        }
        
        DispatchQueue.main.async {
            UIApplication.shared.keyWindow?.addSubview(dialog)
        }
    }
    
    func dismissInvalidNetworkDialog() {
        DispatchQueue.main.async {
            self.invalidNetworkDialog?.removeFromSuperview()
        }
    }
    
    func enterMainPageOnLoginSucess() {
        DispatchQueue.main.async {
            UIApplication.shared.keyWindow?.rootViewController = MainViewController()
        }
    }
}

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

推荐阅读更多精彩内容