使用SwiftUI开发一个APP - 列表视图&网络请求

0. 前言

本来好好的学这ARKit,但是发现ARKit教程中很多设计了SwiftUI和Swift一些编程,所以想着这些迟早也要掌握。所以只能下定决心,新开一个分支来学习SwiftUI以及基于Swift的iOS开发,这样才能为未来的AR开发做好准备。

我个人学习新语言或者框架的流程基本上都是直接想好要做一个什么样的demo,边做边学。所以这次学习SwiftUI的demo就是将我之前做的美剧小程序《Folo美剧》移植到iOS平台。当然感兴趣的同学也可以进入《Folo美剧》逛逛。

image

废话不多说,后续一段时间我会分步骤,把这个移植开发学习过程展现出来。

1. 创建项目

创建项目很简单,记得选择interface 为 SwiftUI,Life Cycle 为 Swift UI APP。

image

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
    }
}
  1. 首先定义一个Resource对象,实现了Codable协议,可用于JSON对象的转换
  2. 通过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<T> { // 1
        let value: T
        let response: URLResponse
    }

    func run<T: Decodable>(_ request: URLRequest) -> AnyPublisher<Response<T>, Error> { // 2
        return URLSession.shared
            .dataTaskPublisher(for: request) // 3
            .tryMap { result -> Response<T> 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
    }
}

代码解读:

  1. 这是我们通用的返回对象。value属性将会是真实的对象,response属性将会是URLResponse,包含了http状态码等。

  2. 这是我们对于网络请求的唯一入口,无论是GET,POST还是其他类型的请求 - 都会在 request的参数中体现。

  3. 我们在这里“将URLSession转换成publisher”

  4. 将结果解码为我们在APIClient中定义的通用类型(这里是ResourceResponse)

  5. 我们自制的Response对象现在包含了真实的数据+URL Response(我们在其中可以找到Http状态码等)

  6. 在主线程中返回结果

  7. 我们通过清除publisher的类型来结束这个请求,因为它有可能非常的长且复杂。接下来,转换并按照我们需要的类型(AnyPublisher<Response<T>, 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<ResourceResponse, Error> {
        // 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
    }
}
  1. 设置好基础的请求需要的内容

  2. 设置好请求的path,这里可以优化的是增加method

  3. 创建URL请求

  4. 设置请求参数

  5. run新创建的request

  6. Map是我们用到的operator,使得我们可以设置我们需要的输出类型。.value在这个例子中是我们通过泛型定义的方法返回值(ResourceResponse), 由于client返回的是一个Resource对象,包含了value 和 response两个属性,但我们目前只需要处理value这个属性。

  7. 这个请求调用清理了返回值类型,从类似Publishers.MapKeyPath<AnyPublisher<APIClient.Response<ResourceResponse>, Error>, T>的结构转换为AnyPublisher<ResourceResponse, Error>结构

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
            })
    }

}

代码解读:

  1. @Published修饰符属性 告知Swift随时关注这个变量的变化。如果发生任何变化,所有视图中使用了该变量的body都将更新。

  2. 订阅者的实现可以使用这个类型(AnyCancellable)来提供一个"取消令牌",这将使得一个调用者取消一个发布者成为可能。需要知道的是,如果你不将你的请求调用赋值给这个类型的变量,那么你的网络请求调用将不会生效。

  3. 我们将在ResourceViewModel刚创建的时候便调用请求获取数据,因为Swift没有我们使用UIKit一样的生命周期。

  4. 这里我们发起请求,获取resource list

  5. 我们在这里处理可能发生的错误

  6. 真正的订阅者在这里创建。就像上面提到的,sink-订阅者使用了一个闭包,来让我们处理接收到的value,当value从发布者那里准备就绪后。

  7. 我们将接收到的数据赋值给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()
        }
    }
}

最终看一下效果吧:

image
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容