触点数字孪生,揭秘它的独特魅力
698
2022-10-01
手把手带你撸一个网易云音乐首页(二)
前言
Hello,大家好,今天是7月份的第一天,记得某人说过:不管你上半年混的有多惨,但请你不要躺平,因为伟大的事业都是在下半年完成的。
好了,废话不多说,咱们继续来接着上一篇文章来往下讲。
构建 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 UIKitimport Alamofireenum MethodType { case get case post}enum NetworkError: Error { case invalidResponse case nilResponse}class NetworkManager
请求返回的 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": " "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: - Welcomestruct HomePage: Codable { let code: Int let data: DataClass let message: String}// MARK: - DataClassstruct DataClass: Codable { let cursor: JSONNull? let blocks: [Block] let hasMore: Bool let blockUUIDs: JSONNull? let pageConfig: PageConfig let guideToast: GuideToast}// MARK: - Blockstruct 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: - Creativestruct 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 = "creativeId" case action, actionType, uiElement, resources, alg, position, code case creativeEXTInfoVO = "creativeExtInfoVO" case source }}// MARK: - CreativeEXTInfoVOstruct CreativeEXTInfoVO: Codable { let playCount: Int}// MARK: - ResourceElementstruct 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 = "resourceId" case resourceURL = "resourceUrl" case resourceEXTInfo = "resourceExtInfo" case action, actionType, valid, alg }}........
接下来,我们开始将 JSON 映射到 Model 中,由于 Alamofire 库已经提供了 Codable, 所以我们只需要处理它的返回值即可:
NetworkManager
ViewModel
Model 已准备完毕,所以接下来我们需要创建 ViewModel,它将负责向我们的 TableView 表视图提供数据。
我们将创建 12 个不同的 Sections,分别是:
Banner圆形按钮推荐歌单个性推荐精选音乐视频雷达歌单音乐日历专属场景歌单云贝新歌播客合辑24小时播客视频合辑
因为我们获取到的数据都不是同一格式的,所以我们需要对每种类型的数据使用不同的 UITableViewCell,因此我们需要使用正确的 ViewModel 结构。
首先,我们必须区分数据类型,以便于我们可以使用正确的 Cell。那该如何去区分呢!是用 if else 还是用 enum 呢!当然在 Swift 中要实现多种类型并且可以轻松切换,最好的方式还是使用枚举,那么就让我们开始构建 ViewModel 吧!
/// 类型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 提供属性计算:
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 Foundationimport UIKitclass 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() -> 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: 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() -> 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() -> 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: CGFloat var frame: CGRect var type: HomeViewModelSectionType { return .STYLE_RCMD } 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 = StyleRcmdModel.caculateFrame() self.rowHeight = self.frame.height } /// 根据模型计算 View frame class 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: CGFloat var frame: CGRect var type: HomeViewModelSectionType { return .PLAYLIST_RCMD } 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 = PlaylistRcmdModel.caculateFrame() self.rowHeight = self.frame.height } /// 根据模型计算 View frame class 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: CGFloat var frame: CGRect var type: HomeViewModelSectionType { return .MUSIC_MLOG } var rowCount: Int{ return 1 } var uiElement: BlockUIElement? var mLog: [EXTInfoElement]! init(mLog: [EXTInfoElement], ui elements: BlockUIElement) { self.mLog = mLog self.uiElement = elements self.frame = MusicMLOGModel.caculateFrame() self.rowHeight = self.frame.size.height } /// 根据模型计算 View frame class 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: CGFloat var frame: CGRect var type: HomeViewModelSectionType { return .OFFICIAL_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 = OfficialPlaylistModel.caculateFrame() self.rowHeight = self.frame.height } /// 根据模型计算 View frame class 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: CGFloat var frame: CGRect var type: HomeViewModelSectionType { return .MUSIC_CALENDAR } 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 = MusicCalendarModel.caculateFrame() self.rowHeight = self.frame.height } /// 根据模型计算 View frame class 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: CGFloat var frame: CGRect var 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 = creatives self.uiElement = elements self.frame = AlbumNewSongModel.caculateFrame() self.rowHeight = self.frame.height } /// 根据模型计算 View frame class 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: CGFloat var frame: CGRect var type: HomeViewModelSectionType { return .PODCAST24 } 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 = Podcast24Model.caculateFrame() self.rowHeight = self.frame.height } /// 根据模型计算 View frame class 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: CGFloat var frame: CGRect var type: HomeViewModelSectionType { return .VOICELIST_RCMD } 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 = VoiceListRcmdModel.caculateFrame() self.rowHeight = self.frame.height } /// 根据模型计算 View frame class 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: CGFloat var frame: CGRect var type: HomeViewModelSectionType { return .VIDEO_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 = VideoPlaylistModel.caculateFrame() self.rowHeight = self.frame.height } /// 根据模型计算 View frame class 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 数组:
/// 首页 ViewModelclass HomeViewModel: NSObject { var sections = [HomeViewModelSection]()}
首先,我们先初始化 ViewModel,将获取到的数据存储到数组中:
/// 首页 ViewModelclass HomeViewModel: NSObject { var sections = [HomeViewModelSection]() weak var delegate: HomeViewModelDelegate? override init() { super.init() fetchData() } // 获取首页数据,异步请求并将数据配置好 func fetchData() { // 1.创建任务组 let queueGroup = DispatchGroup() // 2.获取首页数据 queueGroup.enter() // 请求数据 首页发现 + 圆形图片 NetworkManager
然后再基于 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) 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 == "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
现在,如果要重新排序、添加或删除 item,只需修改此 ViewModel 的 item 数组即可。很清楚,是吧?
接下来,我们将 UITableViewDataSource 添加到 ModelView:
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) -> Int{ return homeViewModel.sections[section].rowCount } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { // configure the cells here
结尾
到此,创建项目工程, App UI 框架,Model, ViewModel 已经基本完成。最后再总结一下,首先在构建 App UI 框架的时候,我们利用 StoryBoard 能快速构建视图的特点搭建了 UI 框架;然后,根据接口返回的 JSON,利用外部转换工具 quicktype 快速生成 Model, 将 JSON 数据映射到 Model 上,我们使用了原生的 Codable 来实现这一映射过程,最后,创建 ViewModel,由于我们的每个 Section 展示的数据都不同,为了方便表视图加载数据,就需要对所有的 Section 加载的数据进行抽象成一个公共类以便调用,所以这里我们使用了协议来处理。
好了,这篇文章到此就结束了,下篇文章我们来讲一下如何构建 View。
先附上项目工程地址:github.com/ShenJieSuzh… 持续更新代码中,喜欢的话记得点个 Star ✨.
版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。