标题前言

Hello,大家好,近期我一直在学习用 Swift 编码,由于之前很多项目我都是用 OC 实现的,所以导致我现在对 Swift 还是处于一个学习的阶段中。为了提高自己的学习效率,每次我都会为自己定下一个短期的目标,就那这次来说吧,为了加快自己上手 Swift, 我为自己定下了的目标就是完成一个 Swift 版本的网易云音乐 App。不知道大家在学习一门新语言的时候,是如何提高学习效率的?不妨在评论区与大家交流一下。

标题调研分析

先分析一下 iOS 端网易云音乐 App 的首页,如图所示:

看完后,首先摆在我眼前的第一个困难就是我该如何去获取这些数据!我的第一个想法当然就是去 GitHub 上找有没有开源的 API,不找不知道,一找果然很满意,原来早就有大佬提供了网易云音乐的 API:

其中就有“首页发现” 和 “首页-发现-圆形图标入口列表” 的 API, 无需我们进行多个接口的调用以及数据源的拼接,就可一获取首页的全部数据啦!在分析返回的 JSON 数据格式的时候,还给大佬提了个issue,大佬也很快的回复了,再次膜拜一下大佬。

{"code": 200,"data": {"cursor": null,"blocks": [{"blockCode": "HOMEPAGE_BANNER","showType": "BANNER","extInfo": {"banners": [{"adLocation": null,"monitorImpress": null,"bannerId": "1622653251261138","extMonitor": null,"pid": null,"pic": "http://p1.music.126.net/gWmqDS3Os7FWFkJ3s8Wotw==/109951166052270907.jpg","program": null,"video": null,"adurlV2": null,"adDispatchJson": null,"dynamicVideoData": null,"monitorType": null,"adid": null,"titleColor": "red","requestId": "","exclusive": false,"scm": "1.music-homepage.homepage_banner_force.banner.2941964.-1777659412.null","event": null,"alg": null,"song": {......
}

数据源的问题解决了,接下来就是该解决如何将数据可视化了,从网易云音乐首页展示的效果分析来看,整体的视图支持上下滚动,其中单个 Cell 的视图支持横向滚动,所以这里采用 UITableView 嵌套 UICollectionView 的方式应该来说再合适不过了。

剩下的就是需要用到的一些第三方库了,在这里我们用到的第三方库如下:

  • Alamofire
  • Kingfisher
  • SnapKit

标题需要实现的功能

作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS开发交流群:710 558 675 ,不管你是小白还是大牛都欢迎入驻 ,让我们一起进步,共同发展!(群内会免费提供一些群主收藏的免费学习书籍资料以及整理好的几百道面试题和答案文档!)

它的首页内容大致可以分为以下几部分:

  1. 顶部搜索视图
  2. Banner
  3. 圆形菜单按钮
  4. 推荐歌单
  5. 个性推荐
  6. 精选音乐视频
  7. 雷达歌单
  8. 热门播客
  9. 专属场景歌单
  10. 新歌,新碟,数字专辑
  11. 音乐日历
  12. 24小时播客
  13. 视频合辑

支持 light Mode 和 Dark Mode 主题

这里先放上我最终实现好了的效果图:

具体的实现细节我会通过两篇文章阐述,功能会按照我上述列出来的功能顺序来一一实现的,废话不多说,咱们继续来接着来往下讲。

构建 App 框架

首先打开我们的 Xcode 创建一个基于 Swift 编程语言的 App 工程,并将它命名。

通过观察网易云音乐 App 的样式,从底部的 TabBar 即可看出它整体的 UI 框架是由 UITabbarController 和 UIViewController 组成的, 所以我们可以通过 StoryBoard 将我们的 App 的整体 UI 架构搭建起来;有的人可能会说我不会用 StoryBoard, 我用纯代码可以搭建吗?答案当然是可以的, 因为我的开发习惯就是简单的 UI 用 Storyboard 拖拖拽拽,复杂的 UI 用代码编写,这纯属于个人习惯,怎么适合自己怎么来就行。

使用 Storyboard 搭建的效果图如下:

构建首页发现视图

我们需要构建的页面是这样的:

通过上面展示的页面,我们可以发现网易云音乐的首页内容展示的数据非常的丰富,有搜索栏,有定时滚动的 Banner,有横向滚动的卡片视图,自身还支持 上拉刷新和下拉刷新,所以我们的首页可以采用 UITableView 来作为容器,然后在 Cell 上构建相应的子视图,例如 Banner, UICollectionView 等,来实现首页这一表视图。

通常我们在用 UITableView 加载数据的时候,数据的类型都是单一类似的,所以我们在构建 Cell 的时候,都是复用的同一个 Cell,类似手机通讯录一样。但是网易云音乐首页可不是那么回事了,它的每个 Cell 呈现的内容类型都是不同的,这就导致我们无法通过复用 Cell 的方式来呈现数据了, 那怎么样才能构建出正确的视图呢!

首先,我们先来确定问题。

你或许可以经常在别的项目中看到这样的代码,在 UITableView 中根据 index 来配置 UITableViewCell:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {if indexPath.row == 0 {//configure cell type 1} else if indexPath.row == 1 {//configure cell type 2}....
}

同样的在代理方法 didSelectRowAt 中使用同样的逻辑:

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {if indexPath.row == 0 {//configure action when tap cell 1} else if indexPath.row == 1 {//configure action when tap cell 1}....
}

那这么写有什么问题吗?

如果你的这个表视图是静态的,不存在重新排序或者在表视图里添加或删除 Cell,那么这样写一点问题也没有。直到你想对表视图进行上面所说的这些操作的时候,那么表视图的结构都将被你破坏,这就需要你手动去更新 cellForRowAt 和 didSelectRowAt 方法中所有的 index 了。

那有什么更好的办法吗?

在接下来的内容中,我会尽我所能与大家分享这个问题的解决思路。

MVVM

在这个项目中,我们将使用 MVVM 模式,MVVM 代表 Model-View-ViewModel, 这种模式的好处在于可以让视图与模型独立出来,降低耦合,从而来减轻 Controller 的体积。

Model

在上一篇文章中,我们已经确定了获取数据源的接口,接下来就是如何去请求数据了?

在这里我用到的网路请求库是一个第三方的开源库: Alamofire,简单的将它的请求接口封装一下,代码如下:

import UIKit
import Alamofireenum MethodType {case getcase post
}enum NetworkError: Error {case invalidResponsecase nilResponse
}class NetworkManager<T: Codable> {// 网络请求static func requestData(_ type: MethodType,URLString: String,parameters: [String : Any]?,completion: @escaping (Result<T, NetworkError>) -> Void) {let method = type == .get ? HTTPMethod.get : HTTPMethod.postAF.request(URLString, method: method, parameters: parameters, encoding: URLEncoding.httpBody).validate().responseDecodable(of: T.self) { response inif let value = response.value {completion(.success(value))return}if let error = response.error {completion(.failure(.invalidResponse))return}completion(.failure(.nilResponse))}}
}

请求返回的 JSON 数据格式如下:

{"code": 200,"data": {"cursor": null,"blocks": [{"blockCode": "HOMEPAGE_BANNER","showType": "BANNER","extInfo": {"banners": [{"adLocation": null,"monitorImpress": null,"bannerId": "1622653251261138","extMonitor": null,"pid": null,"pic": "http://p1.music.126.net/gWmqDS3Os7FWFkJ3s8Wotw==/109951166052270907.jpg","program": null,"video": null,"adurlV2": null,"adDispatchJson": null,"dynamicVideoData": null,"monitorType": null,"adid": null,"titleColor": "red","requestId": "","exclusive": false,"scm": "1.music-homepage.homepage_banner_force.banner.2941964.-1777659412.null","event": null,"alg": null,"song": {...... (省略部分)
}

现在,我们需要创建一个 Model, 将我们请求到的 JSON 映射到我们创建的 Model 上。iOS 原生或第三方开源库有许多可以在 Swift 中解析 JSON 的方式,你可以使用你喜欢的那个,例如
SwiftyJSON,HandyJSON 等,在这个工程中,我坚持使用原生的 Codable 来实现 JSON/Model 的相互转换。

在创建 Model 的时候,我们还可以利用一些外部的工具,来快速的创建 Model,比如在这里我要推荐给大家的一个工具:quicktype,它可以根据提供的 JSON 字符串生成相应的 Model, 可以很大程度上节约我们手动编码创建 Model 的时间。

创建的 Model 如下:

// MARK: - Welcome
struct HomePage: Codable {let code: Intlet data: DataClasslet message: String
}// MARK: - DataClass
struct DataClass: Codable {let cursor: JSONNull?let blocks: [Block]let hasMore: Boollet blockUUIDs: JSONNull?let pageConfig: PageConfiglet guideToast: GuideToast
}// MARK: - Block
struct Block: Codable {let blockCode, showType: Stringlet extInfo: EXTInfoUnion?let canClose: Boollet action: String?let actionType: ActionType?let uiElement: BlockUIElement?let creatives: [Creative]?
}enum ActionType: String, Codable {case clientCustomized = "client_customized"case orpheus = "orpheus"
}// MARK: - Creative
struct Creative: Codable {let creativeType: Stringlet creativeID, action: String?let actionType: ActionType?let uiElement: CreativeUIElement?let resources: [ResourceElement]?let alg: String?let position: Intlet code: String?let logInfo: String? = ""let creativeEXTInfoVO: CreativeEXTInfoVO?let source: String?enum CodingKeys: String, CodingKey {case creativeTypecase creativeID = "creativeId"case action, actionType, uiElement, resources, alg, position, codecase creativeEXTInfoVO = "creativeExtInfoVO"case source}
}// MARK: - CreativeEXTInfoVO
struct CreativeEXTInfoVO: Codable {let playCount: Int
}// MARK: - ResourceElement
struct ResourceElement: Codable {let uiElement: ResourceUIElementlet resourceType: Stringlet resourceID: Stringlet resourceURL: String?let resourceEXTInfo: ResourceEXTInfo?let action: Stringlet actionType: ActionTypelet valid: Boollet alg: String?let logInfo: String? = ""enum CodingKeys: String, CodingKey {case uiElement, resourceTypecase resourceID = "resourceId"case resourceURL = "resourceUrl"case resourceEXTInfo = "resourceExtInfo"case action, actionType, valid, alg}
}........ (由于代码篇幅过长,省略部分)
复制代码

接下来,我们开始将 JSON 映射到 Model 中,由于 Alamofire 库已经提供了 Codable, 所以我们只需要处理它的返回值即可:

    NetworkManager<Menus>.requestData(.get, URLString: NeteaseURL.Menu.urlString, parameters: nil) { result inswitch result {case .success(let response):let data: [Datum] = response.datalet model: MenusModel = MenusModel(data: data)case .failure(let error):print(error.localizedDescription)}}

ViewModel

Model 已准备完毕,所以接下来我们需要创建 ViewModel,它将负责向我们的 TableView 表视图提供数据。

我们将创建 12 个不同的 Sections,分别是:

  • Banner
  • 圆形按钮
  • 推荐歌单
  • 个性推荐
  • 精选音乐视频
  • 雷达歌单
  • 音乐日历
  • 专属场景歌单
  • 云贝新歌
  • 播客合辑
  • 24小时播客
  • 视频合辑

因为我们获取到的数据都不是同一格式的,所以我们需要对每种类型的数据使用不同的 UITableViewCell,因此我们需要使用正确的 ViewModel 结构。

首先,我们必须区分数据类型,以便于我们可以使用正确的 Cell。那该如何去区分呢!是用 if else 还是用 enum 呢!当然在 Swift 中要实现多种类型并且可以轻松切换,最好的方式还是使用枚举,那么就让我们开始构建 ViewModel 吧!

/// 类型
enum HomeViewModelSectionType {case BANNER             // Bannercase MENUS              // 圆形按钮case PLAYLIST_RCMD      // 推荐歌单case STYLE_RCMD         // 个性推荐case MUSIC_MLOG         // 精选音乐视频case MGC_PLAYLIST       // 雷达歌单case MUSIC_CALENDAR     // 音乐日历case OFFICIAL_PLAYLIST  // 专属场景歌单case ALBUM_NEW_SONG     // 云贝新歌case VOICELIST_RCMD     // 播客合辑case PODCAST24          // 24小时播客case VIDEO_PLAYLIST     // 视频合辑
}

每个 enum case 表示 TableViewCell 需要的不同的数据类型。但是,由于我们希望在表视图中都使用相同类型的数据,所以我们需要将这些 case 都抽象出来,定义一个单独的公共类,它将决定所有属性。在这里,我们可以通过使用协议来实现这一点,该协议将为我们的 item 提供属性计算:

protocol HomeViewModelSection {...
}

首先,我们需要知道的是 item 的类型, 因此我们需要为协议创建一个类型属性 ,并指定该属性是 gettable 还是 settable。在我们的例子中,类型将是 HomeViewModelSection:

protocol HomeViewModelSection {var type: HomeViewModelSectionType { get }
}

我们需要的下一个属性是 rowCount。它将告诉我们每个 section 有多少行:

protocol HomeViewModelSection {var type: HomeViewModelSectionType { get }var rowCount: Int { get }
}

我们还需要在协议中添加俩个属性,分别是 rowHeight 和 frame。它们将定义 Section 的高度和尺寸:

protocol HomeViewModelSection {var type: HomeViewModelSectionType { get }var rowCount: Int { get }var rowHeight: CGFloat { get }var frame: CGRect { get set }
}

现在,我们已经准备好为每种数据类型创建 ViewModelItem。每个 item 都需要遵守前面定义好的协议。但在我们开始之前,让我们再向简洁有序的项目迈出一步:为我们的协议提供一些默认值。在 swift 中,我们可以使用协议扩展 extension 为协议提供默认值, 这样我们就不必为每个 item 的 rowCount 赋值了,省去一些冗余的代码:

extension HomeViewModelSection {var rowCount: Int {return 1}
}

先为 Banner Cell 创建一个 ViewModeItem:

import Foundation
import UIKitclass BannerModel: HomeViewModelSection {var frame: CGRectvar type: HomeViewModelSectionType {return .BANNER}var rowCount: Int{return 1}var rowHeight:CGFloatvar banners: [Banner]!init(banners: [Banner]) {self.banners = bannersself.frame = BannerModel.caculateFrame()self.rowHeight = self.frame.size.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionD_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}

然后我们可以创建剩余的 11 个 ViewModeItem:

class MenusModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .MENUS}var rowCount: Int{return 1}var data: [Datum]!init(data: [Datum]) {self.data = dataself.frame = MenusModel.caculateFrame()self.rowHeight = self.frame.size.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionC_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class MgcPlaylistModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .MGC_PLAYLIST}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = MgcPlaylistModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class StyleRcmdModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .STYLE_RCMD}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = StyleRcmdModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionE_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class PlaylistRcmdModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .PLAYLIST_RCMD}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = PlaylistRcmdModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class MusicMLOGModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .MUSIC_MLOG}var rowCount: Int{return 1}var uiElement: BlockUIElement?var mLog: [EXTInfoElement]!init(mLog: [EXTInfoElement], ui elements: BlockUIElement) {self.mLog = mLogself.uiElement = elementsself.frame = MusicMLOGModel.caculateFrame()self.rowHeight = self.frame.size.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class OfficialPlaylistModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .OFFICIAL_PLAYLIST}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = OfficialPlaylistModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class MusicCalendarModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .MUSIC_CALENDAR}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = MusicCalendarModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionB_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class AlbumNewSongModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .ALBUM_NEW_SONG}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = AlbumNewSongModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class Podcast24Model: HomeViewModelSection
{var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .PODCAST24}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = Podcast24Model.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class VoiceListRcmdModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .VOICELIST_RCMD}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = VoiceListRcmdModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class VideoPlaylistModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .VIDEO_PLAYLIST}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = VideoPlaylistModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}

然后我们可以创建剩余的 11 个 ViewModeItem:

class MenusModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .MENUS}var rowCount: Int{return 1}var data: [Datum]!init(data: [Datum]) {self.data = dataself.frame = MenusModel.caculateFrame()self.rowHeight = self.frame.size.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionC_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class MgcPlaylistModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .MGC_PLAYLIST}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = MgcPlaylistModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class StyleRcmdModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .STYLE_RCMD}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = StyleRcmdModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionE_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class PlaylistRcmdModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .PLAYLIST_RCMD}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = PlaylistRcmdModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class MusicMLOGModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .MUSIC_MLOG}var rowCount: Int{return 1}var uiElement: BlockUIElement?var mLog: [EXTInfoElement]!init(mLog: [EXTInfoElement], ui elements: BlockUIElement) {self.mLog = mLogself.uiElement = elementsself.frame = MusicMLOGModel.caculateFrame()self.rowHeight = self.frame.size.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class OfficialPlaylistModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .OFFICIAL_PLAYLIST}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = OfficialPlaylistModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class MusicCalendarModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .MUSIC_CALENDAR}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = MusicCalendarModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionB_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class AlbumNewSongModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .ALBUM_NEW_SONG}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = AlbumNewSongModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class Podcast24Model: HomeViewModelSection
{var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .PODCAST24}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = Podcast24Model.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class VoiceListRcmdModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .VOICELIST_RCMD}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = VoiceListRcmdModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}class VideoPlaylistModel: HomeViewModelSection {var rowHeight: CGFloatvar frame: CGRectvar type: HomeViewModelSectionType {return .VIDEO_PLAYLIST}var rowCount: Int{return 1}var creatives: [Creative]!var uiElement: BlockUIElement?init(creatives: [Creative], ui elements: BlockUIElement) {self.creatives = creativesself.uiElement = elementsself.frame = VideoPlaylistModel.caculateFrame()self.rowHeight = self.frame.height}/// 根据模型计算 View frameclass func caculateFrame() -> CGRect {let height: CGFloat = sectionA_height * CGFloat(scaleW)let width: CGFloat = CGFloat(kScreenWidth)return CGRect(x: 0, y: 0, width: width, height: height)}
}

这就是数据项所需的全部内容。

最后一步是创建 ViewModel 类。这个类可以被任何 ViewController 使用,这也是 MVVM 结构背后的关键思想之一:你的 ViewModel 对 View 一无所知,但它提供了 View 可能需要的所有数据。

ViewModel 拥有的唯一属性是 item 数组,它对应着 UITableView 包含的 section 数组:

/// 首页 ViewModel
class HomeViewModel: NSObject {var sections = [HomeViewModelSection]()}

首先,我们先初始化 ViewModel,将获取到的数据存储到数组中:

/// 首页 ViewModel
class HomeViewModel: NSObject {var sections = [HomeViewModelSection]()weak var delegate: HomeViewModelDelegate?override init() {super.init()fetchData()}// 获取首页数据,异步请求并将数据配置好func fetchData() {// 1.创建任务组let queueGroup = DispatchGroup()// 2.获取首页数据queueGroup.enter()// 请求数据 首页发现 + 圆形图片NetworkManager<HomePage>.requestData(.get, URLString: NeteaseURL.Home.urlString, parameters: nil) { result inswitch result {case .success(let response):// 拆分数据模型到各个板块self.sections = self.splitData(data: response.data.blocks)queueGroup.leave()case .failure(let error):print(error.localizedDescription)self.delegate?.onFetchFailed(with: error.localizedDescription)queueGroup.leave()}}// 3\. 异步获取首页圆形按钮queueGroup.enter()NetworkManager<Menus>.requestData(.get, URLString: NeteaseURL.Menu.urlString, parameters: nil) { result inswitch result {case .success(let response):// 拆分数据模型到各个板块let data: [Datum] = response.datalet model: MenusModel = MenusModel(data: data)if self.sections.count > 0 {self.sections.insert(model, at: 1)}queueGroup.leave()case .failure(let error):print(error.localizedDescription)self.delegate?.onFetchFailed(with: error.localizedDescription)queueGroup.leave()}}// 4\. 执行结果queueGroup.notify(qos: .default, flags: [], queue: .main) {// 数据回调给 view, 结束 loading 并加载数据self.delegate?.onFetchComplete()}}
}

然后再基于 ViewModelItem 的属性类型,配置需要显示的 ViewModel。

/// 拆分已解析好的数据到各个数据模型/// - Parameter data: 首页发现数据模型func splitData(data: [Block]) -> [HomeViewModelSection]{var array: [HomeViewModelSection] = [HomeViewModelSection]()for item in data {if item.blockCode == "HOMEPAGE_BANNER" || item.blockCode == "HOMEPAGE_MUSIC_MLOG"{switch item.extInfo {case .extInfoElementArray(let result):// 精选音乐视频let model: MusicMLOGModel = MusicMLOGModel(mLog: result, ui: item.uiElement!)array.append(model)breakcase .purpleEXTInfo(let result):// BANNERlet banner: [Banner] = result.bannerslet model: BannerModel = BannerModel(banners: banner)array.append(model)breakcase .none:break}} else if item.blockCode == "HOMEPAGE_BLOCK_PLAYLIST_RCMD" {// 推荐歌单let ui: BlockUIElement = item.uiElement!let creatives: [Creative] = item.creatives!let model: PlaylistRcmdModel = PlaylistRcmdModel(creatives: creatives, ui: ui)array.append(model)} else if item.blockCode == "HOMEPAGE_BLOCK_STYLE_RCMD" {// 个性推荐let ui: BlockUIElement = item.uiElement!let creatives: [Creative] = item.creatives!let model:StyleRcmdModel = StyleRcmdModel(creatives: creatives, ui: ui)array.append(model)}  else if item.blockCode == "HOMEPAGE_BLOCK_MGC_PLAYLIST" {// 网易云音乐的雷达歌单let ui: BlockUIElement = item.uiElement!let creatives: [Creative] = item.creatives!let model:MgcPlaylistModel = MgcPlaylistModel(creatives: creatives, ui: ui)array.append(model)} else if item.blockCode == "HOMEPAGE_MUSIC_CALENDAR" {// 音乐日历let ui: BlockUIElement = item.uiElement!let creatives: [Creative] = item.creatives!let model:MusicCalendarModel = MusicCalendarModel(creatives: creatives, ui: ui)array.append(model)} else if item.blockCode == "HOMEPAGE_BLOCK_OFFICIAL_PLAYLIST" {// 专属场景歌单let ui: BlockUIElement = item.uiElement!let creatives: [Creative] = item.creatives!let model:OfficialPlaylistModel = OfficialPlaylistModel(creatives: creatives, ui: ui)array.append(model)} else if item.blockCode == "HOMEPAGE_BLOCK_NEW_ALBUM_NEW_SONG" {// 新歌let ui: BlockUIElement = item.uiElement!let creatives: [Creative] = item.creatives!let model: AlbumNewSongModel = AlbumNewSongModel(creatives: creatives, ui: ui)array.append(model)} else if item.blockCode == "HOMEPAGE_VOICELIST_RCMD" {// 播客合辑let ui: BlockUIElement = item.uiElement!let creatives: [Creative] = item.creatives!let model: VoiceListRcmdModel = VoiceListRcmdModel(creatives: creatives, ui: ui)array.append(model)} else if item.blockCode == "HOMEPAGE_PODCAST24" {// 24小时播客let ui: BlockUIElement = item.uiElement!let creatives: [Creative] = item.creatives!let model: Podcast24Model = Podcast24Model(creatives: creatives, ui: ui)array.append(model)} else if item.blockCode == "HOMEPAGE_BLOCK_VIDEO_PLAYLIST" {// 视频合辑let ui: BlockUIElement = item.uiElement!let creatives: [Creative] = item.creatives!let model: VideoPlaylistModel = VideoPlaylistModel(creatives: creatives, ui: ui)array.append(model)}}return array}

现在,如果要重新排序、添加或删除 item,只需修改此 ViewModel 的 item 数组即可。很清楚,是吧?

接下来,我们将 UITableViewDataSource 添加到 ModelView:

extension DiscoveryViewController {// Mark UITableViewDataSourceoverride func numberOfSections(in tableView: UITableView) -> Int {if homeViewModel.sections.isEmpty {return 0}return homeViewModel.sections.count}override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int{return homeViewModel.sections[section].rowCount}override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {// configure the cells here}
}

结尾

作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS开发交流群:710 558 675 ,不管你是小白还是大牛都欢迎入驻 ,让我们一起进步,共同发展!(群内会免费提供一些群主收藏的免费学习书籍资料以及整理好的几百道面试题和答案文档!)

到此,创建项目工程, App UI 框架,Model, ViewModel 已经基本完成。最后再总结一下,首先在构建 App UI 框架的时候,我们利用 StoryBoard 能快速构建视图的特点搭建了 UI 框架;然后,根据接口返回的 JSON,利用外部转换工具 quicktype 快速生成 Model, 将 JSON 数据映射到 Model 上,我们使用了原生的 Codable 来实现这一映射过程,最后,创建 ViewModel,由于我们的每个 Section 展示的数据都不同,为了方便表视图加载数据,就需要对所有的 Section 加载的数据进行抽象成一个公共类以便调用,所以这里我们使用了协议来处理。

好了,这篇文章到此就结束了,下篇文章我们来讲一下如何构建 View。

带你手把手撸一个网易云音乐首页(上篇)相关推荐

  1. 【PyQt5】教你一招,分分钟撸一个网易云音乐的UI界面

    很多小伙伴自己在用pyqt5做UI时看到自己做的界面,都会觉得巨~丑~无~比~,反正我开始是这样的,但某天当我突然学会某个设置后,我感觉自己开窍了! 废话不多说,先上原图!这是网易云音乐的软件截图! ...

  2. 用Flutter撸一个网易云音乐App

    直接上地址GitHub 示例

  3. 实现一个网易云音乐的 BottomSheetDialog

    作者:林冠宏 / 指尖下的幽灵 掘金:https://juejin.im/user/587f0dfe128fe100570ce2d8 博客:http://www.cnblogs.com/linguan ...

  4. 20230410----重返学习-网易云音乐首页案例-audio音频标签

    day-046-forty-six-20230410-网易云音乐首页案例-audio音频标签 网易云音乐首页案例 单例设计模式 const theModule = (function(){let th ...

  5. 实现 60fps 的网易云音乐首页

    网易云音乐是一款很优秀的音乐软件,我也是它的忠实用户.最近在研究如何更好的开发TableView,接着我写了一个Model驱动的小框架 - MDTable.为了去验证框架的可用性,我选择了网易云音乐的 ...

  6. axure如何实现跳转_Axure教程:网易云音乐首页原型设计

    作者利用Axure动态面板功能对网易云音乐首页进行了一个简单的原型设计,那如何利用Axure动态面板功能实现轮播效果及交互功能以及隐藏丑陋的滚动条呢?下面来和我一起研究一下~ 制作示例如下: 网易云音 ...

  7. CSS——网易云音乐首页之榜单区域的实现

    文章目录 前言 一.结构的布局 1.示例图 2.结构分析 二.实现过程 1.HTML结构 2.CSS样式 总结 前言 本文主要介绍了网易云音乐首页榜单区域的实现过程. 一.结构的布局 示例图: 结构分 ...

  8. CSS——网易云音乐首页之热门推荐歌单的制作

    文章目录 前言 一.结构的布局 二.实现过程 1.HTML结构 2.CSS样式 总结 前言 本文主要讲述了网易云音乐首页的热门推荐歌单部分的实现过程,我将从先分析大致结构与布局,其次书写样式的顺序进行 ...

  9. CSS——网易云音乐首页之轮播图的实现(完整版)

    文章目录 前言 一.结构的分析 二.使用步骤 1.HTML结构 2.CSS样式 总结 前言 本文主要介绍的是网易云音乐首页之轮播图的实现,细心的小伙伴会发现,之前有发过网易轮播图的制作方法,但并没有完 ...

最新文章

  1. ffmpeg 解码rtp方法
  2. php 判断时间超过5分钟_小学音乐20分钟试讲面试,只有5天复习时间可以逆袭吗?...
  3. Linux 查看当前用户id和组id
  4. 网络验证常见的攻击方式与防御手段
  5. vant组件搜索并选择_Vue.js自定义弹层组件|vue仿微信/ios弹窗
  6. Apple Pay 支付集成
  7. 20.1 shell脚本介绍 20.2 shell脚本结构和执行 20.3 date命令用法 20.4 shell脚本中的变量...
  8. 【连载】【FPGA黑金开发板】NIOS II那些事儿--SDRAM实验(十二)
  9. 晶振 Crystal
  10. 录屏直播时,只有部分屏幕的解决办法
  11. matlab 波束图,Matlab波束形成程序
  12. 熵的性质:可加性和强可加性
  13. AI时代,陪孩子玩什么游戏?| 前Google资深工程师实战心法
  14. python 爬取图片网站图片链接并下载收集
  15. 计算机科学与技术 美国 研究生 gpa3.5 托福100,历年录取数据告诉你:美国研究生申请TOEFL、GRE、GPA需要考多少分?...
  16. 二、不浪费原料的汉堡制作方案(Weekly Contest 165)
  17. python matplotlib的常见参数以及画图示例
  18. 湖泊遥感研究进展(概述)
  19. Ilog、Drools、Jess规则引擎的Rule Language 对比
  20. MainWindow.xaml.cs

热门文章

  1. lol3月17日服务器维护,前沿手游:LOL3月17日维护补偿领取地址 3月17日维护延长补偿奖励...
  2. Python小猪佩奇来拜年
  3. 应用下载需警惕,“猜你妹”病毒潜伏应用市场伺机刷流氓应用
  4. 试试Nitrate?快速启动的几个方法
  5. Python 出生日期与天干地支属相星座
  6. antd中表单输入自定义校验明明输入了内容还是提示为空请输入
  7. 大师挑战(武僧在擂台上的对抗)
  8. 导入excel每行的图片并上传(参考。因为不一定适合你)
  9. 怎么在html中加入pjax,PJAX技术
  10. java pjax_GitHub - szyjava/pjax: ajax + history.pushState = pjax