美文网首页
MVP 中试用面向接口编程优化代码

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

作者: 黑羽肃霜 | 来源:发表于2019-08-05 22:51 被阅读0次

    问题出现的背景

    当我们需要规定一类抽象行为的时候,是使用基类继承的方式还是组合的方式呢?
    在使用 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()
            }
        }
    }
    
    

    相关文章

      网友评论

          本文标题:MVP 中试用面向接口编程优化代码

          本文链接:https://www.haomeiwen.com/subject/wyxbdctx.html