前言
方案實作
原始需求
隐藏需求
方案制定
具體實作
總結
附錄
前言
網際網路内容已經逐漸從圖文閱讀往如今火熱的短視訊更疊,某種程度上短視訊有着圖文所沒有的優勢和不可替代性,降低了自我表達的門檻。近期疊代做了個短視訊清單滾動自動播放的需求,上線了一段時間。覺得略有趣,簡單分享下方案。
本文提供了 Demo ,将方案進行簡化處理,隻包含核心的功能實作。
本文的 Demo 附在文末
方案實作

介紹下整個方案的思考和實作的一些過程。
原始需求
簡化如下:
自動播放條件
Wifi環境下,當視訊中心位置從下往上越過螢幕的2/3位置,或從上往下越過螢幕的1/3位置時,視訊開始自動播放;
停止播放條件
當視訊中心位置離開可見區域時,視訊自動停止播放。若下一個視訊滿足開始自動播放的條件,則上一個視訊自動停止播放。
手動播放
點播放按鈕可手動開始播放。開始播放某視訊後,其他視訊停止播放。
隐藏需求
原始需求背後需要考慮的其他情況:
頁面進入/離開的處理
App進入背景/傳回前台的處理
從一個視訊清單跳轉到另一個視訊清單的處理
頁面包含安全區的處理
清單滾動播放的性能問題
視訊循環播放/靜音功能
上拉加載更多/下拉重新整理等動作觸發的滾動的處理
清單 cell 的複用問題
方案制定
主要考慮了以下一些方面:
優先考慮的是性能問題,同一個清單中盡量隻有一個視訊控件
盡量降低方案的侵入性,目前有多個現有清單需要支援該功能,涉及到多個 controller 和多個 cell ,侵入性低也利于改動和維護
隐藏需求會影響到自動播放邏輯的調用時機
大緻思路如下:
由一個類管理整個 App 的視訊滾動播放的相關邏輯,包括視訊元件。
由 controller 監控滾動,觸發管理類進行處理。管理類計算目前符合自動播放的視訊,播放并将視訊元件嵌入 cell 中。
為了降低侵入性,采用協定實作管理類和 controller、管理類和 cell 間的通信。也利于改動邏輯時隻改動到管理類,而不是牽涉各個調用處和 cell。
隐藏需求處理:
頁面進入/離開的處理、從一個視訊清單跳轉到另一個視訊清單的處理
在 controller 的生命周期中去調用管理類方法進行處理。離開時停止目前播放中的視訊,進入時播放目前清單的視訊。進入時判斷資料源是否已經擷取,已經擷取則調用播放。另外在資料源擷取後判斷是否已經 Appear,是則調用播放。用變量标志避免兩個邏輯重複調用。
App進入背景/傳回前台的處理
App 進入背景/傳回前台不會調用 controller 的生命周期,需要另外處理。
頁面包含安全區的處理
在計算視訊的相對位置時,将安全區考慮在内進行計算。
清單滾動播放的性能問題
測試 清單滾動停止時 、 實時滾動時 、 滾動降速到一定速度時 等情況下,調用自動播放邏輯的性能和體驗,
視訊循環播放/靜音功能
視訊播放完畢的事件需要通過監聽 NSNotification.Name.AVPlayerItemDidPlayToEndTime 實作,放在管理類中進行處理。
上拉加載更多/下拉重新整理等動作觸發的滾動的處理
在這兩種情況下,需要停止頁面的邏輯調用,直到資料源傳回成功或失敗為止。
清單 cell 的複用問題
複用時需要重新布置frame、清空資料源等。
具體實作
定義管理類 VideoListAutoPlayManager
class VideoListAutoPlayManager {
private init() {
playerVC.player = AVPlayer()
playerVC.showsPlaybackControls = false
playerVC.view.backgroundColor = UIColor.clear
}
static let shared = VideoListAutoPlayManager()
private var playerVC: AVPlayerViewController = AVPlayerViewController()
private var preOffsetY: CGFloat = 0
private var currentPlayingView: VideoPlayable?
}
需要儲存一些資訊和狀态,是以定義成單例。
AVPlayerViewController 自帶控制條,需要隐藏。
視訊播放時背景從黑色開始,會導緻出現先看到封面,然後黑色,然後再播放視訊的問題,設定為透明會讓從封面到視訊的過渡自然。
preOffsetY 記錄目前滾動的 UIScrollView 的 contentOffset.y 。用于在多個視訊滿足自動播放時,通過判斷滾動方向來決定選取哪個視訊自動播放。
currentPlayingView 記錄目前播放中的 cell。用于通知上一個播放的 cell 即将停止播放視訊,友善 cell 處理另外的邏輯。
定義協定
protocol VideoPlayable: UIView {
var viewToContainVideo: UIView {get}
var urlToPlay: URL? {get}
func videoStatusChanged(changeTo isPlaying: Bool)
}
protocol VideoListPlayable: UIScrollView {
var visibleViews: [VideoPlayable] {get}
}
extension UITableView: VideoListPlayable {
var visibleViews: [VideoPlayable] {
let views: [VideoPlayable] = visibleCells.compactMap({ $0 as? VideoPlayable })
return views
}
}
extension UICollectionView: VideoListPlayable {
var visibleViews: [VideoPlayable] {
let views: [VideoPlayable] = visibleCells.compactMap({ $0 as? VideoPlayable })
return views
}
}
第一個協定,VideoPlayable,是存放視訊的 cell 需要實作的。實作協定傳回需要包含視訊的 view ,需要播放的視訊 URL,以及用于 VideoListAutoPlayManager 通知 cell 處理視訊播放狀态變化的調用方法。
第二個協定,VideoListPlayable,是滾動清單需要實作的。實作協定返復原動清單目前可見的 cell,用于 VideoListPlayable 去判斷哪些視訊需要自動播放。
兩個協定都遵循某個類,UIView 或 UIScrollView,是有些取巧,友善後面取 frame 等。也可以不遵循,然後在協定中傳回需要的資料即可。
另外為 UITableView 和 UICollectionView 做了預設實作。
觸發滾動播放的處理
func scrollViewDidScroll(_ scrollView: VideoListPlayable) {
let currentOffsetY = scrollView.contentOffset.y
let minY = scrollView.frame.height / 3
let maxY = minY * 2
// 擷取在 scrollView 自動播放區域内的視訊
let autoPlayableViews = scrollView.visibleViews.filter { view in
guard let relativeRect = relativeRect(view: view.viewToContainVideo, relativeTo: scrollView), view.urlToPlay != nil else {return false}
let containerCenterY = relativeRect.minY + relativeRect.height / 2
return (containerCenterY > minY && containerCenterY < maxY)
}
guard let first = autoPlayableViews.first else {
// 沒有需要自動播放的視訊
// 移除目前正在離開/已經離開螢幕的視訊
removeCurrentVideoIfLeavingScreen(scrollView: scrollView)
preOffsetY = currentOffsetY
return
}
// 取出需要自動播放的視訊
let viewToPlay: VideoPlayable = autoPlayableViews.reduce(first) { (result, view) in
let isScrollToUpper = currentOffsetY view.frame.maxY ? (isScrollToUpper ? view : result) : (isScrollToUpper ? result : view)
}
if let currentPlayingView = currentPlayingView, viewToPlay as UIView == currentPlayingView {
// 滿足條件的視訊正在播放中
preOffsetY = currentOffsetY
return
}
removeCurrentVideo(on: scrollView)
addPlayerView(to: viewToPlay, on: scrollView)
preOffsetY = currentOffsetY
}
VideoListAutoPlayManager 提供該方法用于 controller 需要進行視訊自動播放處理時進行調用。
外部可以自行決定在什麼時機,進行視訊自動播放邏輯的觸發,不需要是在 scrollViewDidScroll 的時機。
該方法主要邏輯是:
取出目前可見區域中,滿足自動播放條件(func relativeRect(view: UIView, relativeTo scrollView: VideoListPlayable) -> CGRect?)的 cell,即相對位置為滾動清單的 1/3 至 2/3 的位置。
如果沒有滿足條件的,則判斷目前是否有播放中的視訊,且視訊即将或已經離開螢幕,有則停止播放視訊,并通知 cell。
如果有滿足條件的視訊,則根據滾動方向選取視訊(清單向上滾動時,播放靠下的視訊,反之則播放靠上的視訊),移除上一個播放中的視訊(通知對應的 cell),切換視訊源并播放,通知最新播放的 cell。
手動播放的處理
func play(at videoView: VideoPlayable, on scrollView: VideoListPlayable) {
removeCurrentVideo(on: scrollView)
addPlayerView(to: videoView, on: scrollView)
}
即移除目前播放中的視訊,并将目前手動指定播放的視訊進行播放。
添加視訊元件
func addPlayerView(to view: VideoPlayable, on scrollView: VideoListPlayable) {
guard let url = view.urlToPlay else {
return
}
let avItem = AVPlayerItem(url: url)
let avPlayer = AVPlayer(playerItem: avItem)
playerVC.player = avPlayer
avPlayer.isMuted = true
avPlayer.play()
view.videoStatusChanged(changeTo: true)
let containerView = view.viewToContainVideo
containerView.addSubview(playerVC.view)
playerVC.view.translatesAutoresizingMaskIntoConstraints = false
playerVC.view.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true
playerVC.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true
playerVC.view.leftAnchor.constraint(equalTo: containerView.leftAnchor).isActive = true
playerVC.view.rightAnchor.constraint(equalTo: containerView.rightAnchor).isActive = true
currentPlayingView = view
}
通過協定擷取包含視訊的 view,将視訊放入其中,通知 cell 進行狀态變化處理。
隐藏需求的實作
基本按照上一個部分講的思路實作,沒有将這部分代碼放到 Demo 中。
總結
目前的方案,cell隻需實作協定,添加一個用于包含視訊的 view 即可。這樣降低了對原代碼的侵入性、減少修改和維護的成本,可随時去除該自動播放的特性。另外隐藏需求實際花費的思考和時間會比原始需求多些,需要考慮很多細節。