手把手带你撸一个网易云音乐首页(二)

前言

Hello,大家好,转眼已经来到了7月份,记得鲁迅说过:不管你上半年混的有多惨,请不要气馁,因为伟大的事业都是在下半年完成的。

废话不多说了,咱们继续来接着上一篇文章“手把手带你撸一个网易云音乐首页”来往下讲。

构建 App 框架

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

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

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

image

构建首页发现视图

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

image

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

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

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

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

代码语言:javascript
复制
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 中使用同样的逻辑:

代码语言:javascript
复制
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,简单的将它的请求接口封装一下,代码如下:

代码语言:javascript
复制
import UIKit
import Alamofire

enum MethodType {
case get
case post
}

enum NetworkError: Error {
case invalidResponse
case 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.post
    
    AF.request(URLString, method: method, parameters: parameters, encoding: URLEncoding.httpBody)
        .validate()
        .responseDecodable(of: T.self) { response in
            if let value = response.value {
                completion(.success(value))
                return
            }
            
            if let error = response.error {
                completion(.failure(.invalidResponse))
                return
            }
            
            completion(.failure(.nilResponse))
    }
}

}

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

代码语言:javascript
复制
{
"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 如下:

代码语言:javascript
复制
// MARK: - Welcome
struct HomePage: Codable {
let code: Int
let data: DataClass
let message: String
}

// MARK: - DataClass
struct DataClass: Codable {
let cursor: JSONNull?
let blocks: [Block]
let hasMore: Bool
let blockUUIDs: JSONNull?
let pageConfig: PageConfig
let guideToast: GuideToast
}

// MARK: - Block
struct Block: Codable {
let blockCode, showType: String
let extInfo: EXTInfoUnion?
let canClose: Bool
let 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: String
let creativeID, action: String?
let actionType: ActionType?
let uiElement: CreativeUIElement?
let resources: [ResourceElement]?
let alg: String?
let position: Int
let code: String?
let logInfo: String? = ""
let creativeEXTInfoVO: CreativeEXTInfoVO?
let source: String?

enum CodingKeys: String, CodingKey {
    case creativeType
    case creativeID = &#34;creativeId&#34;
    case action, actionType, uiElement, resources, alg, position, code
    case creativeEXTInfoVO = &#34;creativeExtInfoVO&#34;
    case source
}

}

// MARK: - CreativeEXTInfoVO
struct CreativeEXTInfoVO: Codable {
let playCount: Int
}

// MARK: - ResourceElement
struct ResourceElement: Codable {
let uiElement: ResourceUIElement
let resourceType: String
let resourceID: String
let resourceURL: String?
let resourceEXTInfo: ResourceEXTInfo?
let action: String
let actionType: ActionType
let valid: Bool
let alg: String?
let logInfo: String? = ""

enum CodingKeys: String, CodingKey {
    case uiElement, resourceType
    case resourceID = &#34;resourceId&#34;
    case resourceURL = &#34;resourceUrl&#34;
    case resourceEXTInfo = &#34;resourceExtInfo&#34;
    case action, actionType, valid, alg
}

}

........ (由于代码篇幅过长,省略部分)

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

代码语言:javascript
复制
    NetworkManager<Menus>.requestData(.get, URLString: NeteaseURL.Menu.urlString, parameters: nil) { result in
switch result {
case .success(let response):
let data: [Datum] = response.data
let 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 吧!

代码语言:javascript
复制
/// 类型
enum HomeViewModelSectionType {
case BANNER // Banner
case 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 提供属性计算:

代码语言:javascript
复制
protocol HomeViewModelSection {
...
}

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

代码语言:javascript
复制
protocol HomeViewModelSection {
var type: HomeViewModelSectionType { get }
}

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

代码语言:javascript
复制
protocol HomeViewModelSection {
var type: HomeViewModelSectionType { get }
var rowCount: Int { get }
}

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

代码语言:javascript
复制
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 赋值了,省去一些冗余的代码:

代码语言:javascript
复制
extension HomeViewModelSection {
var rowCount: Int {
return 1
}
}

先为 Banner Cell 创建一个 ViewModeItem:

代码语言:javascript
复制
import Foundation
import UIKit

class BannerModel: HomeViewModelSection {
var frame: CGRect

var type: HomeViewModelSectionType {
    return .BANNER
}

var rowCount: Int{
    return 1
}

var rowHeight:CGFloat

var banners: [Banner]!

init(banners: [Banner]) {
    self.banners = banners
    self.frame = BannerModel.caculateFrame()
    self.rowHeight = self.frame.size.height
}

/// 根据模型计算 View frame
class func caculateFrame() -&gt; 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:

代码语言:javascript
复制
class MenusModel: HomeViewModelSection {
var rowHeight: CGFloat

var frame: CGRect

var type: HomeViewModelSectionType {
    return .MENUS
}

var rowCount: Int{
    return 1
}

var data: [Datum]!

init(data: [Datum]) {
    self.data = data
    self.frame = MenusModel.caculateFrame()
    self.rowHeight = self.frame.size.height
}

/// 根据模型计算 View frame
class func caculateFrame() -&gt; 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: CGFloat

var frame: CGRect

var type: HomeViewModelSectionType {
    return .MGC_PLAYLIST
}

var rowCount: Int{
    return 1
}

var creatives: [Creative]!
var uiElement: BlockUIElement?

init(creatives: [Creative], ui elements: BlockUIElement) {
    self.creatives = creatives
    self.uiElement = elements
    self.frame = MgcPlaylistModel.caculateFrame()
    self.rowHeight = self.frame.height
}

/// 根据模型计算 View frame
class func caculateFrame() -&gt; 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 数组:

代码语言:javascript
复制
/// 首页 ViewModel
class HomeViewModel: NSObject {
var sections = HomeViewModelSection

}

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

代码语言:javascript
复制
/// 首页 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&lt;HomePage&gt;.requestData(.get, URLString: NeteaseURL.Home.urlString, parameters: nil) { result in
        switch 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&lt;Menus&gt;.requestData(.get, URLString: NeteaseURL.Menu.urlString, parameters: nil) { result in
        switch result {
        case .success(let response):
            // 拆分数据模型到各个板块
            let data: [Datum] = response.data
            let model: MenusModel = MenusModel(data: data)
            if self.sections.count &gt; 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。

代码语言:javascript
复制
/// 拆分已解析好的数据到各个数据模型
/// - Parameter data: 首页发现数据模型
func splitData(data: [Block]) -> [HomeViewModelSection]{
var array: [HomeViewModelSection] = HomeViewModelSection

    for item in data {
        if item.blockCode == &#34;HOMEPAGE_BANNER&#34; || item.blockCode == &#34;HOMEPAGE_MUSIC_MLOG&#34;{
            switch item.extInfo {
            case .extInfoElementArray(let result):
                // 精选音乐视频
                let model: MusicMLOGModel = MusicMLOGModel(mLog: result, ui: item.uiElement!)
                array.append(model)
                break
            case .purpleEXTInfo(let result):
                // BANNER
                let banner: [Banner] = result.banners
                let model: BannerModel = BannerModel(banners: banner)
                array.append(model)
                break
            case .none:
                break
            }
        } else if item.blockCode == &#34;HOMEPAGE_BLOCK_PLAYLIST_RCMD&#34; {
            // 推荐歌单
            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 == &#34;HOMEPAGE_BLOCK_STYLE_RCMD&#34; {
            // 个性推荐
            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 == &#34;HOMEPAGE_BLOCK_MGC_PLAYLIST&#34; {
            // 网易云音乐的雷达歌单
            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 == &#34;HOMEPAGE_MUSIC_CALENDAR&#34; {
            // 音乐日历
            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 == &#34;HOMEPAGE_BLOCK_OFFICIAL_PLAYLIST&#34; {
            // 专属场景歌单
            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 == &#34;HOMEPAGE_BLOCK_NEW_ALBUM_NEW_SONG&#34; {
            // 新歌
            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 == &#34;HOMEPAGE_VOICELIST_RCMD&#34; {
            // 播客合辑
            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 == &#34;HOMEPAGE_PODCAST24&#34; {
            // 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 == &#34;HOMEPAGE_BLOCK_VIDEO_PLAYLIST&#34; {
            // 视频合辑
            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:

代码语言:javascript
复制
extension DiscoveryViewController {
// Mark UITableViewDataSource
override func numberOfSections(in tableView: UITableView) -> Int {
if homeViewModel.sections.isEmpty {
return 0
}
return homeViewModel.sections.count
}

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -&gt; Int{
    return homeViewModel.sections[section].rowCount
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -&gt; UITableViewCell {
   // configure the cells here
}

}

结尾

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

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

原创文章,文笔有限,文中若有不正之处,万望告知。