Swift仿写喜马拉雅FM
前言:
- 最近抽空面了几家公司,大部分都是从基础开始慢慢深入项目和原理。面试内容还是以
OC
为主,但是多数也都会问一下Swift
技术情况,也有例外全程问Swift
的公司(做区块链项目),感觉现在虽然大多数公司任然以OC
做为主开发语言,但是Swift
发展很强势,估计明年Swift5
以后使用会更加广泛。- 另外,如果准备跳槽的话,可以提前投简历抽空面试几家公司,一方面可以通过投递反馈检验简历,另外可以总结面试的大致问题方向有利于做针对性复习,毕竟会用也要会说才行,会说也要能说到重点才行,还有就是心仪的公司一定要留到最后面试。希望都能进一个心仪不坑的公司,,当然也应努力提升自己的技术,不坑公司不坑团队, 好像跑题了!!!
目录:
- 上一个仿写项目
GitHub
:https://github.com/daomoer/YYSwiftProject
项目分析简书地址:Swift仿写有妖气漫画- 本项目开始前准备阶段:Swift高仿喜马拉雅APP之一Charles抓包、图片资源获取等
- 本项目
GitHub
:https://github.com/daomoer/XMLYFM
关于项目:
该项目采用MVC
+MVVM
设计模式,Moya
+SwiftyJSON
+HandyJSON
网络框架和数据解析。数据来源抓包及部分本地json
文件。
使用Xcode9.4
基于Swift4.1
进行开发。
项目中使用到的一些开源库以下列表,在这里感谢作者的开源。
pod 'SnapKit'
pod 'Kingfisher'
#tabbar样式
pod 'ESTabBarController-swift'
#banner滚动图片
pod 'FSPagerView'
pod 'Moya'
pod 'HandyJSON'
pod 'SwiftyJSON'
# 分页
pod 'DNSPageView'
#跑马灯
pod 'JXMarqueeView'
#滚动页
pod 'LTScrollView'
#刷新
pod 'MJRefresh'
#消息提示
pod 'SwiftMessages'
pod 'SVProgressHUD'
#播放网络音频
pod 'StreamingKit'
效果图:
首页分类
我听
发现
我的
播放
项目按照
MVVM
模式进行设计,下面贴一下ViewModel
中接口请求和布局设置方法代码。
import UIKit
import SwiftyJSON
import HandyJSON
class HomeRecommendViewModel: NSObject {
// MARK - 数据模型
var fmhomeRecommendModel:FMHomeRecommendModel?
var homeRecommendList:[HomeRecommendModel]?
var recommendList : [RecommendListModel]?
// Mark: -数据源更新
typealias AddDataBlock = () ->Void
var updataBlock:AddDataBlock?
// Mark:-请求数据
extension HomeRecommendViewModel {
func refreshDataSource() {
//首页推荐接口请求
FMRecommendProvider.request(.recommendList) { result in
if case let .success(response) = result {
//解析数据
let data = try? response.mapJSON()
let json = JSON(data!)
if let mappedObject = JSONDeserializer<FMHomeRecommendModel>.deserializeFrom(json: json.description) { // 从字符串转换为对象实例
self.fmhomeRecommendModel = mappedObject
self.homeRecommendList = mappedObject.list
if let recommendList = JSONDeserializer<RecommendListModel>.deserializeModelArrayFrom(json: json["list"].description) {
self.recommendList = recommendList as? [RecommendListModel]
}
}
}
}
// Mark:-collectionview数据
extension HomeRecommendViewModel {
func numberOfSections(collectionView:UICollectionView) ->Int {
return (self.homeRecommendList?.count) ?? 0
}
// 每个分区显示item数量
func numberOfItemsIn(section: NSInteger) -> NSInteger {
return 1
}
//每个分区的内边距
func insetForSectionAt(section: Int) -> UIEdgeInsets {
return UIEdgeInsetsMake(0, 0, 0, 0)
}
//最小 item 间距
func minimumInteritemSpacingForSectionAt(section:Int) ->CGFloat {
return 0
}
//最小行间距
func minimumLineSpacingForSectionAt(section:Int) ->CGFloat {
return 0
}
// 分区头视图size
func referenceSizeForHeaderInSection(section: Int) -> CGSize {
let moduleType = self.homeRecommendList?[section].moduleType
if moduleType == "focus" || moduleType == "square" || moduleType == "topBuzz" || moduleType == "ad" || section == 18 {
return CGSize.zero
}else {
return CGSize.init(width: YYScreenHeigth, height:40)
}
}
// 分区尾视图size
func referenceSizeForFooterInSection(section: Int) -> CGSize {
let moduleType = self.homeRecommendList?[section].moduleType
if moduleType == "focus" || moduleType == "square" {
return CGSize.zero
}else {
return CGSize.init(width: YYScreenWidth, height: 10.0)
}
}
}
与ViewModel
相对应的是控制器Controller.m
文件中的使用,使用MVVM
可以梳理Controller
看起来更整洁一点,避免满眼的逻辑判断。
lazy var viewModel: HomeRecommendViewModel = {
return HomeRecommendViewModel()
}()
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(self.collectionView)
self.collectionView.snp.makeConstraints { (make) in
make.width.height.equalToSuperview()
make.center.equalToSuperview()
}
self.collectionView.uHead.beginRefreshing()
loadData()
loadRecommendAdData()
}
func loadData(){
// 加载数据
viewModel.updataBlock = { [unowned self] in
self.collectionView.uHead.endRefreshing()
// 更新列表数据
self.collectionView.reloadData()
}
viewModel.refreshDataSource()
}
// MARK - collectionDelegate
extension HomeRecommendController: UICollectionViewDelegateFlowLayout, UICollectionViewDataSource, UICollectionViewDelegate {
func numberOfSections(in collectionView: UICollectionView) -> Int {
return viewModel.numberOfSections(collectionView:collectionView)
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return viewModel.numberOfItemsIn(section: section)
}
//每个分区的内边距
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
return viewModel.insetForSectionAt(section: section)
}
//最小 item 间距
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
return viewModel.minimumInteritemSpacingForSectionAt(section: section)
}
//最小行间距
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return viewModel.minimumLineSpacingForSectionAt(section: section)
}
//item 的尺寸
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return viewModel.sizeForItemAt(indexPath: indexPath)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
return viewModel.referenceSizeForHeaderInSection(section: section)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
return viewModel.referenceSizeForFooterInSection(section: section)
}
首页模块分析:
项目首页推荐模块,根据接口请求数据进行处理,顶部的Banner
滚动图片和分类按钮以及下面的听头条统一划分为HeaderCell
,在这个HeaderCell
中继续划分,顶部Banner
单独处理,下面创建CollectionView
,并把分类按钮和听头条作为两个Section
,其中听头条的实现思路为CollectionCell
,通过定时器控制器自动上下滚动。
首页分区
首页推荐的其他模块根据接口请求得到的
moduleType
进行Section
初始化并返回不同样式的Cell
,另外在该模块中还穿插有广告,广告为单独接口,根据接口返回数据穿插到对应的Section
。
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let moduleType = viewModel.homeRecommendList?[indexPath.section].moduleType
if moduleType == "focus" || moduleType == "square" || moduleType == "topBuzz" {
let cell:FMRecommendHeaderCell = collectionView.dequeueReusableCell(withReuseIdentifier: FMRecommendHeaderCellID, for: indexPath) as! FMRecommendHeaderCell
cell.focusModel = viewModel.focus
cell.squareList = viewModel.squareList
cell.topBuzzListData = viewModel.topBuzzList
cell.delegate = self
return cell
}else if moduleType == "guessYouLike" || moduleType == "paidCategory" || moduleType == "categoriesForLong" || moduleType == "cityCategory"{
///横式排列布局cell
let cell:FMRecommendGuessLikeCell = collectionView.dequeueReusableCell(withReuseIdentifier: FMRecommendGuessLikeCellID, for: indexPath) as! FMRecommendGuessLikeCell
cell.delegate = self
cell.recommendListData = viewModel.homeRecommendList?[indexPath.section].list
return cell
}else if moduleType == "categoriesForShort" || moduleType == "playlist" || moduleType == "categoriesForExplore"{
// 竖式排列布局cell
let cell:FMHotAudiobookCell = collectionView.dequeueReusableCell(withReuseIdentifier: FMHotAudiobookCellID, for: indexPath) as! FMHotAudiobookCell
cell.delegate = self
cell.recommendListData = viewModel.homeRecommendList?[indexPath.section].list
return cell
}else if moduleType == "ad" {
let cell:FMAdvertCell = collectionView.dequeueReusableCell(withReuseIdentifier: FMAdvertCellID, for: indexPath) as! FMAdvertCell
if indexPath.section == 7 {
cell.adModel = self.recommnedAdvertList?[0]
}else if indexPath.section == 13 {
cell.adModel = self.recommnedAdvertList?[1]
}
return cell
}else if moduleType == "oneKeyListen" {
let cell:FMOneKeyListenCell = collectionView.dequeueReusableCell(withReuseIdentifier: FMOneKeyListenCellID, for: indexPath) as! FMOneKeyListenCell
cell.oneKeyListenList = viewModel.oneKeyListenList
return cell
}else if moduleType == "live" {
let cell:HomeRecommendLiveCell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeRecommendLiveCellID, for: indexPath) as! HomeRecommendLiveCell
cell.liveList = viewModel.liveList
return cell
}
else {
let cell:FMRecommendForYouCell = collectionView.dequeueReusableCell(withReuseIdentifier: FMRecommendForYouCellID, for: indexPath) as! FMRecommendForYouCell
return cell
}
}
项目中分区尺寸高度是根据返回数据的Count
进行计算的,其他各模块基本思路相同这里只贴一下首页模块分区的尺寸高度计算。
// item 尺寸
func sizeForItemAt(indexPath: IndexPath) -> CGSize {
let HeaderAndFooterHeight:Int = 90
let itemNums = (self.homeRecommendList?[indexPath.section].list?.count)!/3
let count = self.homeRecommendList?[indexPath.section].list?.count
let moduleType = self.homeRecommendList?[indexPath.section].moduleType
if moduleType == "focus" {
return CGSize.init(width:YYScreenWidth,height:360)
}else if moduleType == "square" || moduleType == "topBuzz" {
return CGSize.zero
}else if moduleType == "guessYouLike" || moduleType == "paidCategory" || moduleType == "categoriesForLong" || moduleType == "cityCategory" || moduleType == "live"{
return CGSize.init(width:YYScreenWidth,height:CGFloat(HeaderAndFooterHeight+180*itemNums))
}else if moduleType == "categoriesForShort" || moduleType == "playlist" || moduleType == "categoriesForExplore"{
return CGSize.init(width:YYScreenWidth,height:CGFloat(HeaderAndFooterHeight+120*count!))
}else if moduleType == "ad" {
return CGSize.init(width:YYScreenWidth,height:240)
}else if moduleType == "oneKeyListen" {
return CGSize.init(width:YYScreenWidth,height:180)
}else {
return .zero
}
}
首页分类模块分析:
首页分类采用的是CollectionView
展示分类列表,点击每个分类Item
进入对应的分类界面,根据categoryId
请求顶部滚动title
数据,另外该数据不包含推荐模块,所以分类整体为两个Controller
,一个为推荐模块,一个为其他分类界面根据不同categoryId
显示不同数据列表(因为该界面数据样式一样都是列表),然后推荐部分按照首页的同等思路根据不同的moduleType
显示不同类型Cell
。
分类
首页Vip模块分析:
首页Vip
模块与推荐模块较为相似,顶部Banner
滚动图片和分类按钮作为顶部Cell
,然后其他Cell
横向显示或者是竖向显示以及显示的Item
数量根据接口而定,分区的标题同样来自于接口数据,点击分区headerVeiw
的更多按钮跳转到该分区模块的更多页面。
首页直播模块分析:
首页直播界面的排版主要分四个部分也就是自定义四个CollectionCell
,顶部分类按钮,接着是Banner
滚动图片Cell
内部使用FSPagerView
实现滚动图片效果,滚动排行榜为Cell
内部嵌套CollectionView
,通过定时器控制CollectionCell
实现自动滚动,接下来就是播放列表了,通过自定义HeaderView
上面的按钮切换,刷新不同类型的播放列表。
直播.png
首页广播模块分析:
首页广播模块主要分三个部分,顶部分类按钮Cell
,中间可展开收起分类Item
,因为接口中返回的是14
个电台分类,收起状态显示7
个电台和展开按钮,展开状态显示14
个电台和收起按钮中间空一格Item
,在ViewModel
中获取到数据之后进行插入图片按钮并根据当前展开或是收起状态返回不同Item
数据来实现这部分功能,剩下的是根据数据接口中的分区显示列表和HeaderView
内容。
点击广播顶部分类Item
跳转到对应界面,但是接口返回的该Item
参数为Url
中拼接的字段例如:url:"iting://open?msg_type=70&api=http://live.ximalaya.com/live-web/v2/radio/national&title=国家台&type=national",所以我们要解析Url
拼接参数为字典,拿到我们所需的跳转下一界面请求接口用到的字段。下面为代码部分:
func getUrlAPI(url:String) -> String {
// 判断是否有参数
if !url.contains("?") {
return ""
}
var params = [String: Any]()
// 截取参数
let split = url.split(separator: "?")
let string = split[1]
// 判断参数是单个参数还是多个参数
if string.contains("&") {
// 多个参数,分割参数
let urlComponents = string.split(separator: "&")
// 遍历参数
for keyValuePair in urlComponents {
// 生成Key/Value
let pairComponents = keyValuePair.split(separator: "=")
let key:String = String(pairComponents[0])
let value:String = String(pairComponents[1])
params[key] = value
}
} else {
// 单个参数
let pairComponents = string.split(separator: "=")
// 判断是否有值
if pairComponents.count == 1 {
return "nil"
}
let key:String = String(pairComponents[0])
let value:String = String(pairComponents[1])
params[key] = value as AnyObject
}
guard let api = params["api"] else{return ""}
return api as! String
}
首页-广播
我听模块分析:
我听模块主页面顶部为自定义HeaderView
,内部循环创建按钮,下面为使用LTScrollView
管理三个子模块的滚动视图,订阅和推荐为固定列表显示接口数据,一键听模块也是现实列表数据,其中有个跑马灯滚动显示重要内容的效果,点击添加频道,跳转更多频道界面,该界面为双TableView
实现联动效果,点击左边分类LeftTableView
对应右边RightTableView
滚动到指定分区,滚动右边RightTableView
对应的左边LeftTableView
滚动到对应分类。
发现模块分析:
发现模块主页面顶部为自定义HeaderView
,内部嵌套CollectionView
创建分类按钮Item
,下面为使用LTScrollView
管理三个子模块的滚动视图,关注和推荐动态类似都是显示图片加文字形式显示动态,这里需要注意的是根据文字内容和图片的张数计算当前Cell
的高度,趣配音就是正常的列表显示。
下面贴一个计算动态发布距当前时间的代码
//MARK: -根据后台时间戳返回几分钟前,几小时前,几天前
func updateTimeToCurrennTime(timeStamp: Double) -> String {
//获取当前的时间戳
let currentTime = Date().timeIntervalSince1970
//时间戳为毫秒级要 / 1000, 秒就不用除1000,参数带没带000
let timeSta:TimeInterval = TimeInterval(timeStamp / 1000)
//时间差
let reduceTime : TimeInterval = currentTime - timeSta
//时间差小于60秒
if reduceTime < 60 {
return "刚刚"
}
//时间差大于一分钟小于60分钟内
let mins = Int(reduceTime / 60)
if mins < 60 {
return "\(mins)分钟前"
}
//时间差大于一小时小于24小时内
let hours = Int(reduceTime / 3600)
if hours < 24 {
return "\(hours)小时前"
}
//时间差大于一天小于30天内
let days = Int(reduceTime / 3600 / 24)
if days < 30 {
return "\(days)天前"
}
//不满足上述条件---或者是未来日期-----直接返回日期
let date = NSDate(timeIntervalSince1970: timeSta)
let dfmatter = DateFormatter()
//yyyy-MM-dd HH:mm:ss
dfmatter.dateFormat="yyyy年MM月dd日 HH:mm:ss"
return dfmatter.string(from: date as Date)
}
发现.png
我的模块分析:
我的界面在这里被划分为了三个模块,顶部的头像、名称、粉丝等一类个人信息作为TableView
的HeaderView
,并且在该HeaderView
中循环创建了已购、优惠券等按钮,然后是Section0
循环创建录音、直播等按钮,下面的Cell
根据dataSource
进行分区显示及每个分区的count
。在我的界面中使用了两个小动画,一个是上下滚动的优惠券引导领取动画,另一个是我要录音一个波状扩散提示录音动画。
下面贴一下波纹扩散动画的代码
import UIKit
class CVLayerView: UIView {
var pulseLayer : CAShapeLayer! //定义图层
override init(frame: CGRect) {
super.init(frame: frame)
let width = self.bounds.size.width
// 动画图层
pulseLayer = CAShapeLayer()
pulseLayer.bounds = CGRect(x: 0, y: 0, width: width, height: width)
pulseLayer.position = CGPoint(x: width/2, y: width/2)
pulseLayer.backgroundColor = UIColor.clear.cgColor
// 用BezierPath画一个原型
pulseLayer.path = UIBezierPath(ovalIn: pulseLayer.bounds).cgPath
// 脉冲效果的颜色 (注释*1)
pulseLayer.fillColor = UIColor.init(r: 213, g: 54, b: 13).cgColor
pulseLayer.opacity = 0.0
// 关键代码
let replicatorLayer = CAReplicatorLayer()
replicatorLayer.bounds = CGRect(x: 0, y: 0, width: width, height: width)
replicatorLayer.position = CGPoint(x: width/2, y: width/2)
replicatorLayer.instanceCount = 3 // 三个复制图层
replicatorLayer.instanceDelay = 1 // 频率
replicatorLayer.addSublayer(pulseLayer)
self.layer.addSublayer(replicatorLayer)
self.layer.insertSublayer(replicatorLayer, at: 0)
}
func starAnimation() {
// 透明
let opacityAnimation = CABasicAnimation(keyPath: "opacity")
opacityAnimation.fromValue = 1.0 // 起始值
opacityAnimation.toValue = 0 // 结束值
// 扩散动画
let scaleAnimation = CABasicAnimation(keyPath: "transform")
let t = CATransform3DIdentity
scaleAnimation.fromValue = NSValue(caTransform3D: CATransform3DScale(t, 0.0, 0.0, 0.0))
scaleAnimation.toValue = NSValue(caTransform3D: CATransform3DScale(t, 1.0, 1.0, 0.0))
// 给CAShapeLayer添加组合动画
let groupAnimation = CAAnimationGroup()
groupAnimation.animations = [opacityAnimation,scaleAnimation]
groupAnimation.duration = 3 //持续时间
groupAnimation.autoreverses = false //循环效果
groupAnimation.repeatCount = HUGE
groupAnimation.isRemovedOnCompletion = false
pulseLayer.add(groupAnimation, forKey: nil)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
}
我的.gif
我的.png
播放模块分析:
播放模块可以说是整个项目主线的终点,前面模块点击跳转进入具体节目界面,主页面顶部为自定义HeaderView
,主要显示该有声读物的一些介绍,背景为毛玻璃虚化,下面为使用LTScrollView
管理三个子模块的滚动视图,简介为对读物和作者的介绍,节目列表为该读物分章节显示,找相似为与此相似的读物,圈子为读者分享圈几个子模块都是简单的列表显示,子模块非固定是根据接口返回数据决定有哪些子模块。
点击节目列表任一Cell
就跳转到播放详情界面,该界面采用分区CollectionCell
,顶部Cell
为整体的音频播放及控制,因为要实时播放音频所以没有使用AVFoudtion
,该框架需要先缓存本地在进行播放,而是使用的三方开源的Streaming
库来在线播放音频,剩下的为作者发言和评论等。
总结:
目前项目中主要模块的界面和功能基本完成,写法也都是比较简单的写法,项目用时很短,目前一些功能模块使用了第三方。接下来
1、准备替换为自己封装的控件
2、把项目中可以复用的部分抽离出来封装为灵活多用的公共组件
3、对当前模块进行一些Bug
修改和当前功能完善。
在这件事情完成之后准备对整体代码进行Review
,之后进行接下来功能模块的仿写。
最后:
感兴趣的朋友可以到GitHub
:https://github.com/daomoer/XMLYFM
下载源码看看,也请多提意见,喜欢的朋友动动小手给点个Star
✨✨