0. 前言
本来好好的学这ARKit,但是发现ARKit教程中很多设计了SwiftUI和Swift一些编程,所以想着这些迟早也要掌握。所以只能下定决心,新开一个分支来学习SwiftUI以及基于Swift的iOS开发,这样才能为未来的AR开发做好准备。
我个人学习新语言或者框架的流程基本上都是直接想好要做一个什么样的demo,边做边学。所以这次学习SwiftUI的demo就是将我之前做的美剧小程序《Folo美剧》移植到iOS平台。当然感兴趣的同学也可以进入《Folo美剧》逛逛。
废话不多说,后续一段时间我会分步骤,把这个移植开发学习过程展现出来。
1. 创建项目
创建项目很简单,记得选择interface 为 SwiftUI,Life Cycle 为 Swift UI APP。
2. 创建数据模型对象
2.1. 明确接口返回数据
首先我想先实现对于“剧集”列表数据的获取和列表展示,所以这里涉及到了“剧集列表”这个接口。具体接口返回值如下:
{
"code": 0,
"msg": "",
"data": [
{
"resource_id": "41561",
"cn_name": "老妈驾到",
"en_name": "Call Your Mother",
"area": "美国",
"category": "喜剧/生活",
"channel": "tv",
"content": "故事讲述一位空巢母亲Jean Raines(Kyra Sedgwick扮演),她想知道,当自己的孩子在千里之外过着最好的生活时,她应该如何结束自己单身生活,于是,她决定与家人在一起,当她重新融入他们的生活时,她的孩子们意识到他们可能比想象中更需要她。",
"play_status": "第1季连载中",
"poster": "https://cdn.bagli.me/cdn/yy_41561.jpg",
"poster_a": "None",
"poster_b": "http://image.jstucdn.com/ftp/2021/0114/b_bc165b839b05d8dd95432263b06a95cf.jpg",
"poster_m": "None",
"poster_s": "None",
"premiere": "2021-01-14 周四",
"remark": "",
"views": 7675,
"score": 6.8,
"season": 1,
"episode": 2,
"create_time": 1610604007,
"update_time": 1611590500
},
{
"resource_id": "41546",
"cn_name": "脏话史",
"en_name": "History of Swear Words",
"area": "美国",
"category": "喜剧",
"channel": "tv",
"content": " 尼古拉斯·凯奇将主持Netflix喜剧节目《脏话史》(History Of Swear Words),探索Fuck、Shit、Bitch、Dick、Pussy、Damn等脏话的起源、流行文化用法、科学和文化影响。",
"play_status": "第1季连载中",
"poster": "https://cdn.bagli.me/cdn/yy_41546.jpg",
"poster_a": "None",
"poster_b": "http://image.jstucdn.com/ftp/2021/0106/b_9862fa958ff5c0a8b2536baf1069903b.png",
"poster_m": "None",
"poster_s": "None",
"premiere": "2021-01-05 周二",
"remark": "",
"views": 33403,
"score": 8.4,
"season": 1,
"episode": 2,
"create_time": 1610125205,
"update_time": 1611590497
}
]
}
2.2. 根据请求返回数据结构,设计数据模型
根据接口返回创建数据模型文件 Resource.swift 具体内容如下:
// ResourceModel.swift
// FoloPro
//
// Created by GUNNER on 2021/7/28.
//
import Foundation
struct Resource: Codable, Identifiable {
var id = UUID()
let resourceId: String
let area: String
let category: String
let channel: String
let cnName: String
let content: String
let enName: String
let playStatus: String
let poster: String
let posterA: String
let posterB: String
let posterM: String
let posterS: String
let premiere: String
let remark: String
let createTime: Int
let updateTime: Int
let season: Int
let episode: Int
let score: Float
let views: Int
enum CodingKeys: String, CodingKey {
case resourceId = "resource_id"
case area
case category
case channel
case cnName = "cn_name"
case content
case enName = "en_name"
case playStatus = "play_status"
case poster
case posterA = "poster_a"
case posterB = "poster_b"
case posterM = "poster_m"
case posterS = "poster_s"
case premiere
case remark
case createTime = "create_time"
case updateTime = "update_time"
case season
case episode
case score
case views
}
}
- 首先定义一个Resource对象,实现了Codable协议,可用于JSON对象的转换
- 通过CodingKeys枚举值,将JSON中的字段与对象中的字段一一对应起来
但是到这里,模型的创建还没有完。因为可以看到,我们请求的返回值里面Resource列表外层还有一层结构,即data, msg, code。所以我们还需要定义一个模型来承接这个外层机构,即创建一个 ResourceResponse.swift 来接收请求的返回值。
// ResourceResponse.swift
// FoloPro
//
// Created by GUNNER on 2021/7/28.
//
import Foundation
struct ResourceResponse: Codable {
let code: Int
let data: [Resource]
let msg: String
enum CodingKeys: String, CodingKey {
case code
case data
case msg
}
}
3. 发送请求
3.1. 创建APIClient来发送请求
创建APICLient.swift文件
// APIClient.swift
// FoloPro
//
// Created by GUNNER on 2021/7/28.
//
import Foundation
import Combine
struct APIClient {
struct Response { // 1
let value: T
let response: URLResponse
}
func run(_ request: URLRequest) -> AnyPublisher, Error> { // 2
return URLSession.shared
.dataTaskPublisher(for: request) // 3
.tryMap { result -> Response in
let value = try JSONDecoder().decode(T.self, from: result.data) // 4
return Response(value: value, response: result.response) // 5
}
.receive(on: DispatchQueue.main) // 6
.eraseToAnyPublisher() // 7
}
}
代码解读:
这是我们通用的返回对象。value属性将会是真实的对象,response属性将会是URLResponse,包含了http状态码等。
这是我们对于网络请求的唯一入口,无论是GET,POST还是其他类型的请求 - 都会在 request的参数中体现。
我们在这里“将URLSession转换成publisher”
将结果解码为我们在APIClient中定义的通用类型(这里是ResourceResponse)
我们自制的Response对象现在包含了真实的数据+URL Response(我们在其中可以找到Http状态码等)
在主线程中返回结果
我们通过清除publisher的类型来结束这个请求,因为它有可能非常的长且复杂。接下来,转换并按照我们需要的类型(AnyPublisher
, Error>)返回。
现在我们有了APIClient,但是我们可以看到这个方法只接受URLRequest作为参数。因此我们需要建立可以满足我们API请求的方法。
3.2. 创建自有的API对象来构建请求
创建文件FoloAPI.swift
// FoloAPI.swift
// FoloPro
//
// Created by GUNNER on 2021/7/28.
//
import Foundation
import Combine
// 1
enum Folo {
static let apiClient = APIClient()
static let baseUrl = URL(string: "https://xxx.com/v1/")!
}
// 2
enum APIPath: String {
case resourceList = "resource/list"
}
extension Folo {
static func request(_ path: APIPath, _ queryItems: [URLQueryItem]) -> AnyPublisher {
// 3
guard var components = URLComponents(url: baseUrl.appendingPathComponent(path.rawValue), resolvingAgainstBaseURL: true)
else { fatalError("Couldn't create URLComponents") }
components.queryItems = queryItems // 4
let request = URLRequest(url: components.url!)
return apiClient.run(request) // 5
.map(\.value) // 6
.eraseToAnyPublisher() // 7
}
}
设置好基础的请求需要的内容
设置好请求的path,这里可以优化的是增加method
创建URL请求
设置请求参数
run新创建的request
Map是我们用到的operator,使得我们可以设置我们需要的输出类型。.value在这个例子中是我们通过泛型定义的方法返回值(ResourceResponse), 由于client返回的是一个Resource对象,包含了value 和 response两个属性,但我们目前只需要处理value这个属性。
这个请求调用清理了返回值类型,从类似Publishers.MapKeyPath
, Error>, T>的结构转换为AnyPublisher 结构
3.3. 通过模型发送请求并获取数据
创建文件ResourceViewModel.swift
// ResourceViewModel.swift
// FoloPro
//
// Created by GUNNER on 2021/7/28.
//
import Foundation
import Combine
class ResourceViewModel: ObservableObject {
@Published var resourceList: [Resource] = [] // 1
var cancellationToken: AnyCancellable? // 2
init() {
getResourceList() // 3
}
}
extension ResourceViewModel {
// Subscriber implementation
func getResourceList() {
let queryItems = [URLQueryItem(name: "page", value: "1")]
cancellationToken = Folo.request(.resourceList, queryItems) // 4
.mapError({ (error) -> Error in // 5
print(error)
return error
})
.sink(receiveCompletion: { _ in }, // 6
receiveValue: {
self.resourceList = $0.data // 7
})
}
}
代码解读:
@Published修饰符属性 告知Swift随时关注这个变量的变化。如果发生任何变化,所有视图中使用了该变量的body都将更新。
订阅者的实现可以使用这个类型(AnyCancellable)来提供一个"取消令牌",这将使得一个调用者取消一个发布者成为可能。需要知道的是,如果你不将你的请求调用赋值给这个类型的变量,那么你的网络请求调用将不会生效。
我们将在ResourceViewModel刚创建的时候便调用请求获取数据,因为Swift没有我们使用UIKit一样的生命周期。
这里我们发起请求,获取resource list
我们在这里处理可能发生的错误
真正的订阅者在这里创建。就像上面提到的,sink-订阅者使用了一个闭包,来让我们处理接收到的value,当value从发布者那里准备就绪后。
我们将接收到的数据赋值给resourceList属性,这将会触发我们在 步骤1中提到的动作。
4. SwiftUI的列表视图
创建ResourceListView.swift文件
// ResourceListView.swift
// FoloPro
//
// Created by GUNNER on 2021/7/28.
//
import SwiftUI
import Foundation
import Combine
func getTimeString(_ timestamp: Int) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(timestamp))
let dateFormatter = DateFormatter()
dateFormatter.timeZone = TimeZone(abbreviation: "GMT") //Set timezone that you want
dateFormatter.locale = NSLocale.current
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm" //Specify your format that you want
let strDate = dateFormatter.string(from: date)
return strDate
}
struct ResourceListView: View {
@ObservedObject var viewModel = ResourceViewModel()
var body: some View {
List(viewModel.resourceList) { resource in
VStack {
HStack(alignment:.top) {
AsyncImage(url: URL(string: resource.poster)!,
placeholder: { Text("Loading ...") },
image: {
Image(uiImage: $0).resizable()
})
.scaledToFit()
.frame(width: 156, height: 240)
VStack (alignment: .leading) {
Text(resource.cnName)
.font(.headline)
.fontWeight(.bold)
Text(resource.enName)
.font(.headline)
.fontWeight(.bold)
Text(resource.playStatus)
.padding(EdgeInsets(top: 10, leading: 0, bottom: 0, trailing: 0))
HStack {
Image("tv1").resizable().frame(width: 18, height: 18)
Text(String(format: "S%d E%d", resource.season, resource.episode))
}.padding(EdgeInsets(top: 5, leading: 0, bottom: 0, trailing: 0))
Text(getTimeString(resource.updateTime)).padding(EdgeInsets(top: 10, leading: 0, bottom: 0, trailing: 0))
}.frame(maxHeight: 200, alignment: .topLeading)
.padding(EdgeInsets(top: 10, leading: 10, bottom: 0, trailing: 10))
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ResourceListView()
}
}
需要提到一点是,通过URL获取图片,这里使用了一个开源的代码AsyncImage,具体参见 https://stackoverflow.com/questions/60677622/how-to-display-image-from-a-url-in-swiftui
并且在系统创建的FoloProApp.swift文件中,更新视图。修改后的代码如下:
// FoloProApp.swift
// FoloPro
//
// Created by GUNNER on 2021/7/28.
//
import SwiftUI
@main
struct FoloProApp: App {
var body: some Scene {
WindowGroup {
ResourceListView()
}
}
}
最终看一下效果吧: