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