Swift 开发 wanandroid 客户端——账户管理模块

这是我参与更文挑战的第28天,活动详情查看: 更文挑战

接受意见

很多朋友都说我是标题党,其实我本人也想这样,可能是自己语文不好导致,于是向掘金群的大佬讨教一个标题,准备都改了。以《Swift 开发 wanandroid 客户端——XXXX》开头。

状态管理

其实说到状态管理的时候,这个概念在前端特别的熟悉,比如Vue之于Vuex,React之于Redux,Flutter之于Provider,这些都是在通过在全局配置一个Store,对于全局可能需要使用的数据进行配置,并且由于是MVVM的设计模式,当Store中的数据改变时,会主动去通知相关绑定页面,数据改变啦,UI也随之改变。

上面都是说的非iOS开发的状态管理,当然随着SwiftUI的崛起,这种思维模式会做到大统一,目前SwiftUI中就有SwiftUIFlux这个框架在做这个事情。

其实伴随着使用RxSwift,在RxSwift中也有介绍其常用架构,这里是中文官方文档中的截图:

Architecture.png

除了MVVM,还有RxFeedback和ReactorKit可以使用,基本上都是前端那一套思想。

虽然我也会Vuex中的那一套思路。不过考虑我是一个iOS开发者,我还是用自己熟悉的那一套来吧。

熟悉方式——使用单例编写账户管理

就我以前的写代码的经验,我一般会写一个用户管理的单例,然后里面存着用户信息到处走,当用用户管理单例中的用户信息有改变的时候,我会考虑发通知,去告诉相关页面去做刷新等操作。

所以我们写来写一个单例:

import Foundation

import RxSwift
import RxCocoa
import NSObject_Rx
import MBProgressHUD

final class AccountManager {
    
    /// 单例
    static let shared = AccountManager()
    
    /// 对外只读是否登录属性
    private(set) var isLogin = BehaviorRelay(value: false)
        
    /// 对外只读用户信息属性
    private(set) var accountInfo: AccountInfo?
    
    /// 私有化初始化方法
    private init() {}
    
}

extension AccountManager {
    /// 已登录请求头处理
    var cookieHeaderValue: String {
        if let username = accountInfo?.username, let password = accountInfo?.password {
          return "loginUserName=\(username);loginUserPassword=\(password)";
        } else {
          return ""
        }
    }
}

extension AccountManager {
    /// 登录成功,保存登录信息
    func saveLoginUsernameAndPassword(info: AccountInfo?, username: String, password: String) {
        accountInfo = info
        accountInfo?.username = username
        accountInfo?.password = password
        
        UserDefaults.standard.setValue(username, forKey: kUsername)
        UserDefaults.standard.setValue(password, forKey: kPassword)
        /// 需要注意赋值顺序,将info赋值给单例后,再改变isLogin的状态才能获取正确的请求头
        isLogin.accept(true)
    }
    
    /// 登出成功,清理登录信息
    func clearAccountInfo() {
        isLogin.accept(false)
        accountInfo = nil
    }
}

extension AccountManager {
    /// 更新收藏夹
    func updateCollectIds(_ collectIds: [Int]) {
        AccountManager.shared.accountInfo?.collectIds = collectIds
    }
}

extension AccountManager {
    /// 获取本地保存用户名
    func getUsername() -> String? {
        return UserDefaults.standard.value(forKey: kUsername) as? String
    }
    
    /// 获取本地保存密码
    func getPassword() -> String? {
        return UserDefaults.standard.value(forKey: kPassword) as? String
    }
    
    /// 自动登录
    func autoLogin() {
        if !isLogin.value {
            guard let username = getUsername(), let password = getPassword() else {
                return
            }
            login(username: username, password: password)
        }
    }
    
    /// 调用登录接口
    func login(username: String, password: String) {
        accountProvider.rx.request(AccountService.login(username, password))
            .map(BaseModel<AccountInfo>.self)
            /// 转为Observable
            .subscribe { baseModel in
                if baseModel.isSuccess {
                    AccountManager.shared.saveLoginUsernameAndPassword(info: baseModel.data, username: username, password: password)
                    DispatchQueue.main.async {
                        MBProgressHUD.showText("登录成功")
                    }
                }
            } onError: { _ in
                
            }.disposed(by: disposeBag)
    }
}

extension AccountManager: HasDisposeBag {}

复制代码

以上代码都不是特别复杂,我会对每一段都进行分析。

Swift的单例写法

Swift中单例书写非常的简洁,static let shared = AccountManager(),调用的时候直接AccountManager.shared即可。

但是同时需要注意2点:

  • class前使用final修饰,如果不使用final修饰,那么这个类是可以继承的,一旦继承了,那么即便上就可以为所欲为了。

  • 私有化初始化方法init(),保证只能通过.shared来过去实例。

是否登录与用户信息保存

这里我使用了两个变量来进行保存,具体是下面这样

private(set) var isLogin = BehaviorRelay(value: false)
        
private(set) var accountInfo: AccountInfo?
复制代码

先说明private(set)这个修饰符,这个修饰符用来表示这个属性对外只读,对内可读可写,保证只能通过这类的方法来修改变量,从一定程度上保证其安全性。

然后,让我们看看这个AccountInfo模型。

AccountInfo模型

struct AccountInfo : Codable {

    let admin : Bool?
    let chapterTops : [Int]?
    var collectIds : [Int]?
    let email : String?
    let icon : String?
    let id : Int?
    let nickname : String?
    var password : String?
    let publicName : String?
    let token : String?
    let type : Int?
    var username : String?
}
复制代码

有几个变量我是通过var来修饰的,比如collectIds,用来表示收藏夹,这个数组变量,会随着用户的操作增加或者变少,在这个代码中有所体现:

func updateCollectIds(_ collectIds: [Int]) {
    AccountManager.shared.accountInfo?.collectIds = collectIds
}
复制代码

usernamepassword保存下来主要是针对有些接口必须登录后方可请求,并且需要在请求头添加数据
!通过对accountInfo进行解包,从而生成cookies,具体如下:

var cookieHeaderValue: String {
    if let username = accountInfo?.username, let password = accountInfo?.password {
      return "loginUserName=\(username);loginUserPassword=\(password)";
    } else {
      return ""
    }
}
复制代码

数据保存和清空

  • 每次登录成功,都会更新更新用户信息:
func saveLoginUsernameAndPassword(info: AccountInfo?, username: String, password: String) {
    /// 赋值个人信息
    accountInfo = info
    /// 赋值用户名
    accountInfo?.username = username
    /// 赋值密码
    accountInfo?.password = password
    
    /// 将关键信息保存到本地,用于每次进入App自动登录使用
    UserDefaults.standard.setValue(username, forKey: kUsername)
    UserDefaults.standard.setValue(password, forKey: kPassword)
    
    /// 改变isLogin状态为true,需要注意赋值顺序,将info赋值给单例后,再改变isLogin的状态才能获取正确的请求头
    isLogin.accept(true)
}
复制代码
  • 每次登出成功,都会清除用户信息:
func clearAccountInfo() {
    isLogin.accept(false)
    accountInfo = nil
}
复制代码

自动登录功能:

之前的代码中我保存的username和password到本地,还可以用来通过App的生命周期去触发。

AccountManager.shared.autoLogin()
复制代码

账户管理模块的使用

在上篇的文章中,我编写了登录、注册模块,其中我的页面也和登录状态有关联。

如下图所示:

IMG_7035.jpg

通过AccountManager中的isLogin属性,来进行数据的绑定,使用了两套不同的数据源来驱动页面:

class MyViewModel: BaseViewModel {
    let logoutDataSource: [My] = [.ranking, .openSource, .login]
    
    let loginDataSource: [My] = [.ranking, .myCoin, .myCollect, .openSource, .logout]
    
    let currentDataSource = BehaviorRelay<[My]>(value: [])
    
    let myCoin = BehaviorRelay<CoinRank?>(value: nil)
    
    private let disposeBag: DisposeBag
    
    init(disposeBag: DisposeBag) {
        self.disposeBag = disposeBag
        super.init()
        
        AccountManager.shared.isLogin.subscribe(onNext: { [weak self] isLogin in
            guard let self = self else { return }
            
            print("\(self.className)收到了关于登录状态的值")
            
            self.currentDataSource.accept(isLogin ? self.loginDataSource : self.logoutDataSource)
            
            if isLogin {
               
                DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                    let result = self.getMyCoin()
                    result.map{ $0.data }
                        /// 去掉其中为nil的值
                        .compactMap{ $0 }
                        .subscribe(onSuccess: { data in
                            self.myCoin.accept(data)
                        }, onError: { error in
                            guard let _ = error as? MoyaError else { return }
                            self.networkError.onNext(())
                        })
                        .disposed(by: disposeBag)
                }
            } else {
                self.myCoin.accept(nil)
            }
        }).disposed(by: disposeBag)
    }
}

extension MyViewModel {
    func getMyCoin() -> Single<BaseModel<CoinRank>> {
        return myProvider.rx.request(MyService.userCoinInfo)
            .map(BaseModel<CoinRank>.self)

    }
    
    func logout() -> Single<BaseModel<String>> {
        return accountProvider.rx.request(AccountService.logout)
            .map(BaseModel<String>.self)
    }
}
复制代码

在MyViewModel的初始化方法中,我对AccountManager.shared.isLogin进行了订阅,分别判断了在非登录和登录上不同的逻辑:

  • 两套数据源logoutDataSourceloginDataSource的切换。

  • 登录过后,进行个人积分的接口的请求。

  • 登出过后,对于个人积分的清空。

大家可以回想一下,如果不是RxSwift编写,这种登录操作前后应该怎么去通知相关页面进行操作呢?

没错,在iOS中是使用通知,在Android中使用的EventBus,其实两者的设计思路基本相同。

总结:

本篇文章我们聊了一下几点:

  • App端开发的状态管理目前正在向前端靠拢,其基本思路就是Redux模式,其中RxSwift中也提供了响应的解决方案。

  • 本篇我还是用了最熟悉的单例模式进行了账户管理模块的实现,主要是我RxSwift都是在边学习边研究,RxFeedback和ReactorKit我确实还没有接触。

  • 举例说明AccountManager在我的页面的使用。

明日继续

其实持续了大半个月的Swift开发wanandroid客户端已经接近尾声了,进度上项目、公众号页面基本一致,而体系页面略有不同,还有加载信息的WebView页面等。

不管怎么样,我都会尽力都讲解一下。

有很多朋友问:这个项目开源吗?

我的回答是,已经开源了,只是没放上来,主要是因为写代码和写文章同步进行,很多代码都还没有整理好,后面必定会放上链接,大家到时候给一个star我就最开心啦!

大家加油。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享