Swift仿写喜马拉雅FM

前言:

  • 最近抽空面了几家公司,大部分都是从基础开始慢慢深入项目和原理。面试内容还是以OC为主,但是多数也都会问一下Swift技术情况,也有例外全程问Swift的公司(做区块链项目),感觉现在虽然大多数公司任然以OC做为主开发语言,但是Swift发展很强势,估计明年Swift5以后使用会更加广泛。
  • 另外,如果准备跳槽的话,可以提前投简历抽空面试几家公司,一方面可以通过投递反馈检验简历,另外可以总结面试的大致问题方向有利于做针对性复习,毕竟会用也要会说才行,会说也要能说到重点才行,还有就是心仪的公司一定要留到最后面试。希望都能进一个心仪不坑的公司,当然也应努力提升自己的技术,不坑公司不坑团队, 好像跑题了!!!

目录:

关于项目:

该项目采用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

分类.png
分类

首页Vip模块分析:

首页Vip模块与推荐模块较为相似,顶部Banner滚动图片和分类按钮作为顶部Cell,然后其他Cell横向显示或者是竖向显示以及显示的Item数量根据接口而定,分区的标题同样来自于接口数据,点击分区headerVeiw的更多按钮跳转到该分区模块的更多页面。

Vip

扫描二维码关注公众号,回复: 3149876 查看本文章

首页直播模块分析:

首页直播界面的排版主要分四个部分也就是自定义四个CollectionCell,顶部分类按钮,接着是Banner滚动图片Cell内部使用FSPagerView实现滚动图片效果,滚动排行榜为Cell内部嵌套CollectionView,通过定时器控制CollectionCell实现自动滚动,接下来就是播放列表了,通过自定义HeaderView上面的按钮切换,刷新不同类型的播放列表。

live.gif
直播.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滚动到对应分类。

listen.gif

发现模块分析:

发现模块主页面顶部为自定义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

我的模块分析:

我的界面在这里被划分为了三个模块,顶部的头像、名称、粉丝等一类个人信息作为TableViewHeaderView,并且在该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库来在线播放音频,剩下的为作者发言和评论等。

play.gif

总结:

目前项目中主要模块的界面和功能基本完成,写法也都是比较简单的写法,项目用时很短,目前一些功能模块使用了第三方。接下来 1、准备替换为自己封装的控件 2、把项目中可以复用的部分抽离出来封装为灵活多用的公共组件 3、对当前模块进行一些Bug修改和当前功能完善。 在这件事情完成之后准备对整体代码进行Review,之后进行接下来功能模块的仿写。

最后:

感兴趣的朋友可以到GitHubgithub.com/daomoer/XML…
下载源码看看,也请多提意见,喜欢的朋友动动小手给点个Star✨✨

猜你喜欢

转载自juejin.im/post/5b97743df265da0af21351aa