读RedditOs源码

首先这个项目是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 = true
        case .downvote:
            likes = false
        case .neutral:
            likes = nil
        }
        return API.shared.POST(endpoint: .vote,
                               params: ["id": name, "dir": "\(vote.rawValue)"])
    }
    
    public mutating func save() -> AnyPublisher<NetworkResponse, Never> {
        saved = true
        return API.shared.POST(endpoint: .save, params: ["id": name])
    }
    
    public mutating func unsave() -> AnyPublisher<NetworkResponse, Never> {
        saved = false
        return API.shared.POST(endpoint: .unsave, params: ["id": name])
    }
}

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

public enum Endpoint {
    case subreddit(name: String, sort: String?)
    case subredditAbout(name: String)
    case subscribe
    case searchSubreddit
    case search
    case searchPosts(name: String)
    case comments(name: String, id: String)
    case accessToken
    case me, mineSubscriptions, mineMulti
    case vote, visits, save, unsave
    case userAbout(username: String)
    case userOverview(usernmame: String)
    case userSaved(username: String)
    case userSubmitted(username: String)
    case userComments(username: String)
    case trendingSubreddits
    
    func 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 in
            return try NetworkError.processResponse(data: data, response: response)
        }
      

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

public class OauthClient: ObservableObject {
    public enum State: Equatable {
        case signedOut
        case refreshing, signinInProgress
        case authenthicated(authToken: String)
    }
    
    struct AuthTokenResponse: Decodable {
        let accessToken: String
        let tokenType: String
        let refreshToken: String?
    }
    
    static public let shared = OauthClient()
    
    @Published public var authState = State.refreshing
    
    // Oauth URL
    private 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().uuidString
    private let redirectURI = "redditos://auth"
    private let duration = "permanent"
    private let type = "code"
    
    // Keychain
    private let keychainService = "com.thomasricouard.RedditOs-reddit-token"
    private let keychainAuthTokenKey = "auth_token"
    private let keychainAuthTokenRefreshToken = "refresh_auth_token"
    
    // Request
    private 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 = nil
            print("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 = .refreshing
            DispatchQueue.main.async {
                self.refreshToken(refreshToken: refreshToken)
            }
        } else {
            authState = .signedOut
        }
        
        //每三十分钟刷新一次
        refreshTimer = Timer.scheduledTimer(withTimeInterval: 60.0 * 30, repeats: true) { _ in
            switch 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 = .signinInProgress
        
        return 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 = .signinInProgress
            requestCancellable = makeOauthPublisher(code: code.value)?
                .receive(on: DispatchQueue.main)
                .sink(receiveCompletion: { _ in },
                receiveValue: { response in
                    let keychain = Keychain(service: self.keychainService)
                    keychain[self.keychainAuthTokenKey] = response.accessToken
                    keychain[self.keychainAuthTokenRefreshToken] = response.refreshToken
                    self.authState = .authenthicated(authToken: response.accessToken)
                })
        }
    }
    
    public func logout() {
        authState = .signedOut
        let keychain = Keychain(service: keychainService)
        keychain[keychainAuthTokenKey] = nil
        keychain[keychainAuthTokenRefreshToken] = nil
    }
    
    private func refreshToken(refreshToken: String) {
        refreshCancellable = makeRefreshOauthPublisher(refreshToken: refreshToken)?
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { _ in },
            receiveValue: { response in
                self.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 Foundation

fileprivate let decoder = JSONDecoder()
fileprivate let encoder = JSONEncoder()
fileprivate let saving_queue = DispatchQueue(label: "redditOS.savingqueue", qos: .background)
//保存数据的协议
protocol PersistentDataStore {
    //需要数据类型,保存文件的名字。还有就是保存和获取的方法
    associatedtype DataType: Codable
    var 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 Combine

public 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 = false
    private var fetchingSubscriptions: [Subreddit] = [] {
        didSet {
            isRefreshingSubscriptions = !fetchingSubscriptions.isEmpty
        }
    }
    
    private var disposables: [AnyCancellable?] = []
    private var authStateCancellable: AnyCancellable?
    private var afterOverview: String?
    
    let persistedDataFilename = "CurrentUserData"
    typealias DataType = SaveData
    struct SaveData: Codable {
        let user: User?
        let subscriptions: [Subreddit]
        let multi: [Multi]
    }
    
    public init() {
        if let data = restorePersistedData() {
            subscriptions = data.subscriptions
            user = data.user
        }
        authStateCancellable = OauthClient.shared.$authState.sink(receiveValue: { state in
            switch state {
            case .signedOut:
                self.user = nil
            case .authenthicated:
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    self.refreshUser()
                    if !self.subscriptionFetched {
                        self.subscriptionFetched = true
                        self.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 in
                print(error)
            }, receiveValue: { user in
                self.user = user
            })
        disposables.append(cancellable)
    }
    
    private func fetchSubscription(after: String?) {
        let cancellable = Subreddit.fetchMine(after: after)
            .receive(on: DispatchQueue.main)
            .sink { subs in
                if 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.fetchingSubscriptions
                    self.fetchingSubscriptions = []
                }
            }
        disposables.append(cancellable)
    }
    
    private func fetchMulti() {
        let cancellable = user?.fetchMulti()
            .receive(on: DispatchQueue.main)
            .sink{ listings in
                self.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 in
                if 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 in
                if 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 in
                self.afterOverview = content.data?.after
                let 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 in
                OauthClient.shared.handleNextURL(url: url)
            }
            .sheet(item: $uiState.presentedSheetRoute, content: { $0.makeView() })
        }

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

.onOpenURL { url in
                OauthClient.shared.handleNextURL(url: url)
            }

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

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

Route的实现如下:

import Foundation
import SwiftUI
import Combine
import Backend

enum 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 searchPostsResult
    
    var id: String {
        switch self {
        case let .user(user):
            return user.id
        case let .subreddit(subreddit):
            return subreddit
        case let .defaultChannel(chanel):
            return chanel.rawValue
        case .searchPostsResult:
            return "searchPostsResult"
        }
    }
    
    @ViewBuilder
    func 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)

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

image.png

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

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

import SwiftUI

public struct RecursiveView<Data, RowContent>: View where Data: RandomAccessCollection,
                                                           Data.Element: Identifiable,
                                                           RowContent: View {
    let data: Data
    let children: KeyPath<Data.Element, Data?>
    let rowContent: (Data.Element) -> RowContent
    
    public init(data: Data, children: KeyPath<Data.Element, Data?>, rowContent: @escaping (Data.Element) -> RowContent) {
        self.data = data
        self.children = children
        self.rowContent = rowContent
    }
    
    public var body: some View {
        ForEach(data) { child in
            if 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 = true
    var content: () -> Content
    var label: () -> Label
    
    var 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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值