首先这个项目是SwiftUI编写的Reddit客户端的项目,项目地址
这里把ModelNetwork作为一个单独的Pacakge,在项目中可以借鉴,把不同功能或者模块分为不同的Package
在这里面,给Model都添加了静态方法,返回的都是AnyPublisher,交给Store处理结果,例如:

extension Comment {public enum Sort: String, CaseIterable {case best = "confidence"case top, new, controversial, old, qa}static public func fetch(subreddit: String, id: String, sort: Sort = .top) -> AnyPublisher<[ListingResponse<Comment>], Never> {let params: [String: String] = ["sort": sort.rawValue]return API.shared.request(endpoint: .comments(name: subreddit, id: id), params: params).subscribe(on: DispatchQueue.global()).replaceError(with: []).eraseToAnyPublisher()}public mutating func vote(vote: Vote) -> AnyPublisher<NetworkResponse, Never> {switch vote {case .upvote:likes = truecase .downvote:likes = falsecase .neutral:likes = nil}return API.shared.POST(endpoint: .vote,params: ["id": name, "dir": "\(vote.rawValue)"])}public mutating func save() -> AnyPublisher<NetworkResponse, Never> {saved = truereturn API.shared.POST(endpoint: .save, params: ["id": name])}public mutating func unsave() -> AnyPublisher<NetworkResponse, Never> {saved = falsereturn API.shared.POST(endpoint: .unsave, params: ["id": name])}
}

接口Endpoint是作为enum的,这样结构就很清晰

public enum Endpoint {case subreddit(name: String, sort: String?)case subredditAbout(name: String)case subscribecase searchSubredditcase searchcase searchPosts(name: String)case comments(name: String, id: String)case accessTokencase me, mineSubscriptions, mineMulticase vote, visits, save, unsavecase userAbout(username: String)case userOverview(usernmame: String)case userSaved(username: String)case userSubmitted(username: String)case userComments(username: String)case trendingSubredditsfunc path() -> String {switch self {case let .subreddit(name, sort):if name == "top" || name == "best" || name == "new" || name == "rising" || name == "hot" {return name} else if let sort = sort {return "r/\(name)/\(sort)"} else {return "r/\(name)"}case .searchSubreddit:return "api/search_subreddits"case .subscribe:return "api/subscribe"case let .comments(name, id):return "r/\(name)/comments/\(id)"case .accessToken:return "api/v1/access_token"case .me:return "api/v1/me"case .mineSubscriptions:return "subreddits/mine/subscriber"case .mineMulti:return "api/multi/mine"case let .subredditAbout(name):return "r/\(name)/about"case .vote:return "api/vote"case .visits:return "api/store_visits"case .save:return "api/save"case .unsave:return "api/unsave"case let .userAbout(username):return "user/\(username)/about"case let .userOverview(username):return "user/\(username)/overview"case let .userSaved(username):return "user/\(username)/saved"case let .userSubmitted(username):return "user/\(username)/submitted"case let .userComments(username):return "user/\(username)/comments"case .trendingSubreddits:return "api/trending_subreddits"case .search:return "search"case let .searchPosts(name):return "r/\(name)/search"}}
}

错误处理也是作为一个enum的,这里面对错误进行处理:

public enum NetworkError: Error {case unknown(data: Data)case message(reason: String, data: Data)case parseError(reason: Error)case redditAPIError(error: RedditError, data: Data)static private let decoder = JSONDecoder()static func processResponse(data: Data, response: URLResponse) throws -> Data {guard let httpResponse = response as? HTTPURLResponse else {throw NetworkError.unknown(data: data)}if (httpResponse.statusCode == 404) {throw NetworkError.message(reason: "Resource not found", data: data)}if 200 ... 299 ~= httpResponse.statusCode {return data} else {do {let redditError = try decoder.decode(RedditError.self, from: data)throw NetworkError.redditAPIError(error: redditError, data: data)} catch _ {throw NetworkError.unknown(data: data)}}}
}

这样请求接口有错误的时候就可以使用tryMap进行处理了:

.tryMap{ data, response inreturn try NetworkError.processResponse(data: data, response: response)}

对于用户认证的处理是单独在一个类里面的,包括登入登出,还有刷新token

public class OauthClient: ObservableObject {public enum State: Equatable {case signedOutcase refreshing, signinInProgresscase authenthicated(authToken: String)}struct AuthTokenResponse: Decodable {let accessToken: Stringlet tokenType: Stringlet refreshToken: String?}static public let shared = OauthClient()@Published public var authState = State.refreshing// Oauth URLprivate let baseURL = "https://www.reddit.com/api/v1/authorize"private let secrets: [String: AnyObject]?private let scopes = ["mysubreddits", "identity", "edit", "save","vote", "subscribe", "read", "submit", "history","privatemessages"]private let state = UUID().uuidStringprivate let redirectURI = "redditos://auth"private let duration = "permanent"private let type = "code"// Keychainprivate let keychainService = "com.thomasricouard.RedditOs-reddit-token"private let keychainAuthTokenKey = "auth_token"private let keychainAuthTokenRefreshToken = "refresh_auth_token"// Requestprivate var requestCancellable: AnyCancellable?private var refreshCancellable: AnyCancellable?private var refreshTimer: Timer?init() {if let path = Bundle.module.path(forResource: "secrets", ofType: "plist"),let secrets = NSDictionary(contentsOfFile: path) as? [String: AnyObject] {self.secrets = secrets} else {self.secrets = nilprint("Error: No secrets file found, you won't be able to login on Reddit")}let keychain = Keychain(service: keychainService)if let refreshToken = keychain[keychainAuthTokenRefreshToken] {authState = .refreshingDispatchQueue.main.async {self.refreshToken(refreshToken: refreshToken)}} else {authState = .signedOut}//每三十分钟刷新一次refreshTimer = Timer.scheduledTimer(withTimeInterval: 60.0 * 30, repeats: true) { _ inswitch self.authState {case .authenthicated(_):let keychain = Keychain(service: self.keychainService)if let refresh = keychain[self.keychainAuthTokenRefreshToken] {self.refreshToken(refreshToken: refresh)}default:break}}}public func startOauthFlow() -> URL? {guard let clientId = secrets?["client_id"] as? String else {return nil}authState = .signinInProgressreturn URL(string: baseURL)!.appending("client_id", value: clientId).appending("response_type", value: type).appending("state", value: state).appending("redirect_uri", value: redirectURI).appending("duration", value: duration).appending("scope", value: scopes.joined(separator: " "))}public func handleNextURL(url: URL) {if url.absoluteString.hasPrefix(redirectURI),url.queryParameters?.first(where: { $0.value == state }) != nil,let code = url.queryParameters?.first(where: { $0.key == type }){authState = .signinInProgressrequestCancellable = makeOauthPublisher(code: code.value)?.receive(on: DispatchQueue.main).sink(receiveCompletion: { _ in },receiveValue: { response inlet keychain = Keychain(service: self.keychainService)keychain[self.keychainAuthTokenKey] = response.accessTokenkeychain[self.keychainAuthTokenRefreshToken] = response.refreshTokenself.authState = .authenthicated(authToken: response.accessToken)})}}public func logout() {authState = .signedOutlet keychain = Keychain(service: keychainService)keychain[keychainAuthTokenKey] = nilkeychain[keychainAuthTokenRefreshToken] = nil}private func refreshToken(refreshToken: String) {refreshCancellable = makeRefreshOauthPublisher(refreshToken: refreshToken)?.receive(on: DispatchQueue.main).sink(receiveCompletion: { _ in },receiveValue: { response inself.authState = .authenthicated(authToken: response.accessToken)let keychain = Keychain(service: self.keychainService)keychain[self.keychainAuthTokenKey] = response.accessToken})}private func makeOauthPublisher(code: String) -> AnyPublisher<AuthTokenResponse, NetworkError>? {let params: [String: String] = ["code": code,"grant_type": "authorization_code","redirect_uri": redirectURI]return API.shared.request(endpoint: .accessToken,basicAuthUser: secrets?["client_id"] as? String,httpMethod: "POST",isJSONEndpoint: false,queryParamsAsBody: true,params: params).eraseToAnyPublisher()}private func makeRefreshOauthPublisher(refreshToken: String) -> AnyPublisher<AuthTokenResponse, NetworkError>? {let params: [String: String] = ["grant_type": "refresh_token","refresh_token": refreshToken]return API.shared.request(endpoint: .accessToken,basicAuthUser: secrets?["client_id"] as? String,httpMethod: "POST",isJSONEndpoint: false,queryParamsAsBody: true,params: params).eraseToAnyPublisher()}
}

对于数据持久化,这里是直接保存Data的:

import Foundationfileprivate let decoder = JSONDecoder()
fileprivate let encoder = JSONEncoder()
fileprivate let saving_queue = DispatchQueue(label: "redditOS.savingqueue", qos: .background)
//保存数据的协议
protocol PersistentDataStore {//需要数据类型,保存文件的名字。还有就是保存和获取的方法associatedtype DataType: Codablevar persistedDataFilename: String { get }func persistData(data: DataType)func restorePersistedData() -> DataType?
}extension PersistentDataStore {func persistData(data: DataType) {saving_queue.async {do {let filePath = try FileManager.default.url(for: .documentDirectory,in: .userDomainMask,appropriateFor: nil,create: false).appendingPathComponent(persistedDataFilename)let archive = try encoder.encode(data)try archive.write(to: filePath, options: .atomicWrite)} catch let error {print("Error while saving: \(error.localizedDescription)")}}}func restorePersistedData() -> DataType? {do {let filePath = try FileManager.default.url(for: .documentDirectory,in: .userDomainMask,appropriateFor: nil,create: false).appendingPathComponent(persistedDataFilename)if let data = try? Data(contentsOf: filePath) {return try decoder.decode(DataType.self, from: data)}} catch let error {print("Error while loading: \(error.localizedDescription)")}return nil}
}

实现这个协议的地方就可以持久化了,例如:

import Foundation
import SwiftUI
import Combinepublic class CurrentUserStore: ObservableObject, PersistentDataStore {public static let shared = CurrentUserStore()@Published public private(set) var user: User? {didSet {saveUser()}}@Published public private(set) var subscriptions: [Subreddit] = [] {didSet {saveUser()}}@Published public private(set) var multi: [Multi] = [] {didSet {saveUser()}}@Published public private(set) var isRefreshingSubscriptions = false@Published public private(set) var overview: [GenericListingContent]?@Published public private(set) var savedPosts: [SubredditPost]?@Published public private(set) var submittedPosts: [SubredditPost]?private var subscriptionFetched = falseprivate var fetchingSubscriptions: [Subreddit] = [] {didSet {isRefreshingSubscriptions = !fetchingSubscriptions.isEmpty}}private var disposables: [AnyCancellable?] = []private var authStateCancellable: AnyCancellable?private var afterOverview: String?let persistedDataFilename = "CurrentUserData"typealias DataType = SaveDatastruct SaveData: Codable {let user: User?let subscriptions: [Subreddit]let multi: [Multi]}public init() {if let data = restorePersistedData() {subscriptions = data.subscriptionsuser = data.user}authStateCancellable = OauthClient.shared.$authState.sink(receiveValue: { state inswitch state {case .signedOut:self.user = nilcase .authenthicated:DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {self.refreshUser()if !self.subscriptionFetched {self.subscriptionFetched = trueself.fetchSubscription(after: nil)self.fetchMulti()}}default:break}})}private func saveUser() {persistData(data: .init(user: user,subscriptions: subscriptions,multi: multi))}private func refreshUser() {let cancellable = User.fetchMe()?.receive(on: DispatchQueue.main).sink(receiveCompletion: { error inprint(error)}, receiveValue: { user inself.user = user})disposables.append(cancellable)}private func fetchSubscription(after: String?) {let cancellable = Subreddit.fetchMine(after: after).receive(on: DispatchQueue.main).sink { subs inif let subscriptions = subs.data?.children {let news = subscriptions.map{ $0.data }self.fetchingSubscriptions.append(contentsOf: news)}if let after = subs.data?.after {self.fetchSubscription(after: after)} else {self.fetchingSubscriptions.sort{ $0.displayName.lowercased() < $1.displayName.lowercased() }self.subscriptions = self.fetchingSubscriptionsself.fetchingSubscriptions = []}}disposables.append(cancellable)}private func fetchMulti() {let cancellable = user?.fetchMulti().receive(on: DispatchQueue.main).sink{ listings inself.multi = listings.map{ $0.data }}disposables.append(cancellable)}public func fetchSaved(after: SubredditPost?) {let cancellable = user?.fetchSaved(after: after).receive(on: DispatchQueue.main).map{ $0.data?.children.map{ $0.data }}.sink{ listings inif self.savedPosts?.last != nil, let listings = listings {self.savedPosts?.append(contentsOf: listings)} else if self.savedPosts == nil {self.savedPosts = listings}}disposables.append(cancellable)}public func fetchSubmitted(after: SubredditPost?) {let cancellable = user?.fetchSubmitted(after: after).receive(on: DispatchQueue.main).map{ $0.data?.children.map{ $0.data }}.sink{ listings inif self.submittedPosts?.last != nil, let listings = listings {self.submittedPosts?.append(contentsOf: listings)} else if self.submittedPosts == nil {self.submittedPosts = listings}}disposables.append(cancellable)}public func fetchOverview() {let cancellable = user?.fetchOverview(after: afterOverview).receive(on: DispatchQueue.main).sink{ content inself.afterOverview = content.data?.afterlet listings = content.data?.children.map{ $0.data }if self.overview?.last != nil, let listings = listings {self.overview?.append(contentsOf: listings)} else if self.overview == nil {self.overview = listings}}disposables.append(cancellable)}}

这个项目里面并没有像objc的SwiftUI和Combine编程里面一样只有一个统一的Store,这里有多个:

WindowGroup {NavigationView {SidebarView()ProgressView()PostNoSelectionPlaceholder().toolbar {PostDetailToolbar(shareURL: nil)}}.frame(minWidth: 1300, minHeight: 600).environmentObject(localData).environmentObject(OauthClient.shared).environmentObject(CurrentUserStore.shared).environmentObject(uiState).environmentObject(searchText).onOpenURL { url inOauthClient.shared.handleNextURL(url: url)}.sheet(item: $uiState.presentedSheetRoute, content: { $0.makeView() })}

可以看见这里有多个environmentObject,分工明确。
这里对于openURL处理是要去刷新token,重新认证:

.onOpenURL { url inOauthClient.shared.handleNextURL(url: url)}

对于弹出框的统一管理是在UIStatepresentedSheetRoute,根据Route弹出不同的框:

.sheet(item: $uiState.presentedSheetRoute, content: { $0.makeView() })

Route的实现如下:

import Foundation
import SwiftUI
import Combine
import Backendenum Route: Identifiable, Hashable {static func == (lhs: Route, rhs: Route) -> Bool {lhs.id == rhs.id}func hash(into hasher: inout Hasher) {hasher.combine(id)}case user(user: User)case subreddit(subreddit: String)case defaultChannel(chanel: UIState.DefaultChannels)case searchPostsResultvar id: String {switch self {case let .user(user):return user.idcase let .subreddit(subreddit):return subredditcase let .defaultChannel(chanel):return chanel.rawValuecase .searchPostsResult:return "searchPostsResult"}}@ViewBuilderfunc makeView() -> some View {switch self {case let .user(user):UserSheetView(user: user)case let .subreddit(subreddit):SubredditPostsListView(name: subreddit).equatable()case let .defaultChannel(chanel):SubredditPostsListView(name: chanel.rawValue).equatable()case .searchPostsResult:QuickSearchPostsResultView()}}
}

使用的时候直接赋值就行了:

uiState.presentedSheetRoute = .user(user: user)

这里尤其要提到的是评论的展示,是有一个递归调用的妙招的:

RecursiveView(data: viewModel.comments ?? placeholderComments,children: \.repliesComments) { comment inCommentRow(comment: comment,isRoot: comment.parentId == "t3_" + viewModel.post.id || viewModel.comments == nil).redacted(reason: viewModel.comments == nil ? .placeholder : [])}

这里的递归调用实现如下:

import SwiftUIpublic struct RecursiveView<Data, RowContent>: View where Data: RandomAccessCollection,Data.Element: Identifiable,RowContent: View {let data: Datalet children: KeyPath<Data.Element, Data?>let rowContent: (Data.Element) -> RowContentpublic init(data: Data, children: KeyPath<Data.Element, Data?>, rowContent: @escaping (Data.Element) -> RowContent) {self.data = dataself.children = childrenself.rowContent = rowContent}public var body: some View {ForEach(data) { child inif self.containsSub(child)  {CustomDisclosureGroup(content: {RecursiveView(data: child[keyPath: children]!,children: children,rowContent: rowContent).padding(.leading, 8)}, label: {rowContent(child)})} else {rowContent(child)}}}func containsSub(_ element: Data.Element) -> Bool {element[keyPath: children] != nil}
}struct CustomDisclosureGroup<Label, Content>: View where Label: View, Content: View {@State var isExpanded: Bool = truevar content: () -> Contentvar label: () -> Labelvar body: some View {HStack(alignment: .top, spacing: 8) {Image(systemName: "chevron.right").rotationEffect(isExpanded ? .degrees(90) : .degrees(0)).padding(.top, 4).onTapGesture {isExpanded.toggle()}label()}if isExpanded {content()}}
}

里面又调用了RecursiveView

读RedditOs源码相关推荐

  1. 读Zepto源码之操作DOM

    2019独角兽企业重金招聘Python工程师标准>>> 这篇依然是跟 dom 相关的方法,侧重点是操作 dom 的方法. 读Zepto源码系列文章已经放到了github上,欢迎sta ...

  2. 读Lodash源码——chunk.js

    The time is out of joint: O cursed spite, That ever I was born to set it right. --莎士比亚 最艰难的第一步 最近学习遇 ...

  3. 试读angular源码第三章:初始化zone

    直接看人话总结 前言 承接上一章 项目地址 文章地址 angular 版本:8.0.0-rc.4 欢迎看看我的类angular框架 文章列表 试读angular源码第一章:开场与platformBro ...

  4. 读spring源码(一)-ClassPathXmlApplicationContext-初始化

    工作来几乎所有的项目都用到了spring,却一直没有系统的读下源码,从头开始系统的读下吧,分章也不那么明确,读到哪里记到哪里,仅仅作为个笔记吧. 先看ClassPathXmlApplicationCo ...

  5. 读tomcat源码,随笔类图

    by yan 20170425 读tomcat源码,随笔类图:

  6. 【读fastclick源码有感】彻底解决tap“点透”,提升移动端点击响应速度

    前言 近期使用tap事件为老夫带来了这样那样的问题,其中一个问题是解决了点透还需要将原来一个个click变为tap,这样的话我们就抛弃了ie用户 当然可以做兼容,但是没人想动老代码的,于是今天拿出了f ...

  7. 读 zepto 源码之工具函数

    对角另一面 读 zepto 源码之工具函数 Zepto 提供了丰富的工具函数,下面来一一解读. 源码版本 本文阅读的源码为 zepto1.2.0 $.extend $.extend 方法可以用来扩展目 ...

  8. 读zepto源码之工具函数

    2019独角兽企业重金招聘Python工程师标准>>> Zepto 提供了丰富的工具函数,下面来一一解读. 源码版本 本文阅读的源码为 zepto1.2.0 $.extend $.e ...

  9. java 事件分发机制_读Android源码之事件分发机制最全总结

    原标题:读Android源码之事件分发机制最全总结 本文源码来自andorid sdk 22,不同版本会有细微差别,但核心机制是一致的 一.概述 事件分发有多种类型, 本文主要介绍Touch相关的事件 ...

最新文章

  1. 一些算法入门应该明白的东西
  2. es6之扩展运算符...
  3. response.redirect 正在中止线程
  4. Python天天美味(13) - struct.unpack
  5. 简述单机,集群,分布式架构区别及联系
  6. CakePHP Pagination (分頁功能) 加入自己的參數
  7. RabbitMQ系列教程之一:我们从最简单的事情开始!Hello World
  8. asp.net电子商务开发实战 视频 第二讲 (下)
  9. 谷歌android go 销量,谷歌Android Go进入尴尬期:目前仅十多个App专门适配
  10. 无法打开包括文件:“afxcontrolbars.h”
  11. 代码对比工具 Top5
  12. 《电路分析导论(原书第12版)》一词汇表
  13. 2.4 数值分析: Doolittle直接三角分解法
  14. 计算机网络的文件怎么删除,教你一招如何删除Win7电脑中的顽固文件
  15. SpringBoot统一返回结果
  16. 8.如何在idea打开一个已有项目
  17. Abnova 基因 FISH 探针丨CCND1(橙色)FISH 探针
  18. 作业3 跟踪分析Linux内核的启动过程
  19. 数据推荐 | 自然对话语音数据集
  20. 以管理员身份在当前目录打开命令行窗口

热门文章

  1. 健身泡沫轴可以带上高铁吗_15大最佳泡沫轴肌肉放松训练,90%的健身者都不会!...
  2. 【Win10】我们无法更新系统保留的分区
  3. JS自制简易版数字华容道小游戏
  4. 【Bootstrap】Bootstrap v5 nav导航条实现部分居左,部分居右布局
  5. 公安大学计算机专业怎么样,计算机专业中国人民公安大学在职研究生2017读研如何...
  6. 对阿,为什么大学程序设计老师不去外面公司当程序员?
  7. 各大电商平台商品详情、商品信息实时数据api
  8. 运行scrapy demo时报错:[twisted] CRITICAL: Unhandled error in Deferred
  9. python元组和列表字典_Python【列表 字典 元组】
  10. 远程连接Linux服务器并实现文件的上传下载