首先这个项目是SwiftUI
编写的Reddit客户端的项目,项目地址
这里把Model
和Network
作为一个单独的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)
}
复制代码
对于弹出框的统一管理是在UIState
的presentedSheetRoute
,根据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)
复制代码
这里尤其要提到的是评论的展示,是有一个递归调用的妙招的:
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
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END