如何避免CollectionView reloadData时当前可见单元格内代码执行?
这是一个很常见的CollectionView分页+视频播放的坑,我来帮你分析问题根源并给出几个可行的解决方案:
问题背景
我在做一个全屏视频的CollectionView,同一时间只显示一个单元格,上下滚动时视频启停正常。但分页加载新数据调用reloadData()时出现问题:当用户滚动到倒数第二个条目触发分页,若此时该条目正在播放视频,reloadData()会让新增的每个条目都执行cellForItemAt,导致当前播放的视频被中断。我尝试在Video模型中添加isUserWatchingVideo属性拦截,但没生效,请问怎么阻止reloadData()时当前可见单元格内的播放代码执行?
相关代码
class Video { var url: URL? var videoId: String? var postDate: Double? var isUserWatchingVideo = false } var dataSource = [Video]() func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "MyCell", for: indexPath) as! MyCell cell.currentItem = indexPath.item cell.video = dataSource[indexPath.item] return cell } func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { // 停止视频播放 guard let cell = cell as? MyCell else { return } cell.player.pause() // 重置状态 dataSource[indexPath.item].isUserWatchingVideo = false } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { handlePagination() } func handlePagination() { // 模拟获取新视频数据 let newVideos = [Video]() // 实际是请求到的新数据 dataSource.append(contentsOf: newVideos) // 按发布日期排序 dataSource.sort(by: { $0.postDate ?? 0 > $1.postDate ?? 0 }) // 问题出在这里:reloadData会触发所有可见cell的cellForItem和didEndDisplaying collectionView.reloadData() } // 自定义Cell class MyCell: UICollectionViewCell { var currentItem: Int? var player: AVPlayer = AVPlayer() var video: Video? { didSet { guard let video = video else { return } // 这里的判断没生效,因为reloadData时cell被重新赋值,isUserWatchingVideo可能被重置? if video.isUserWatchingVideo { return } video.isUserWatchingVideo = true // 初始化播放逻辑 guard let url = video.url else { return } let asset = AVAsset(url: url) let playerItem = AVPlayerItem(asset: asset) player.replaceCurrentItem(with: playerItem) player.play() } } }
核心问题分析
你遇到的问题本质是reloadData()会强制刷新所有可见单元格,导致当前正在播放的cell被重新复用/赋值,触发video的didSet逻辑,而之前的isUserWatchingVideo判断失效,主要原因是:
- 分页后排序改变了原播放条目的索引,
currentItem与数据源的对应关系错乱 reloadData()时didEndDisplaying会被调用,重置了原播放条目的isUserWatchingVideo为false,之后cellForItem重新赋值时又会触发播放
解决方案
方案1:避免全局reloadData,使用批量更新(推荐)
既然分页后需要排序,我们可以先计算新增数据插入的位置,然后用performBatchUpdates来更新CollectionView,这样只会刷新新增的单元格,不会影响当前可见的播放单元格。
修改handlePagination():
func handlePagination() { // 保存当前数据源的长度,用于后续判断新增数据 let originalCount = dataSource.count // 获取新视频数据(替换为实际请求逻辑) let newVideos = fetchNewVideos() // 先添加到数据源再排序 dataSource.append(contentsOf: newVideos) dataSource.sort(by: { $0.postDate ?? 0 > $1.postDate ?? 0 }) // 计算新增数据在排序后的indexPaths let newIndexPaths = dataSource.enumerated() .filter { originalCount..<dataSource.count ~= $0.offset } .map { IndexPath(item: $0.offset, section: 0) } // 使用批量更新插入新数据,不影响已有可见单元格 collectionView.performBatchUpdates({ collectionView.insertItems(at: newIndexPaths) }, completion: nil) } // 模拟获取新数据的方法 private func fetchNewVideos() -> [Video] { // 实际这里是网络请求逻辑 return [Video]() }
注意:如果你的
Video类有唯一标识(比如videoId),也可以用它来精准判断新增数据,避免依赖索引范围:extension Video: Equatable { static func == (lhs: Video, rhs: Video) -> Bool { return lhs.videoId == rhs.videoId } } // 替换上面的newIndexPaths计算逻辑 let newIndexPaths = dataSource.enumerated() .filter { newVideos.contains($0.element) } .map { IndexPath(item: $0.offset, section: 0) }
这个方案从根源上避免了全局刷新,是最优解,完全不会干扰当前正在播放的视频。
方案2:维护当前播放索引,精准控制播放逻辑
如果必须使用reloadData(),可以在ViewController中维护当前正在播放的视频索引,在cell的赋值逻辑中精准判断,避免重复初始化播放。
步骤1:在ViewController中添加播放索引记录
var currentPlayingIndex: Int? // 记录当前正在播放的视频索引
步骤2:修改CollectionView代理方法
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "MyCell", for: indexPath) as! MyCell cell.currentItem = indexPath.item cell.video = dataSource[indexPath.item] // 如果是当前播放的索引,直接恢复播放状态 if indexPath.item == currentPlayingIndex { cell.player.play() } // 设置回调,让cell通知VC播放状态变化 cell.onVideoStart = { [weak self] in self?.currentPlayingIndex = indexPath.item } return cell } func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard let cell = cell as? MyCell else { return } cell.player.pause() // 只有当该单元格不是当前播放的索引时,才重置状态 if indexPath.item != currentPlayingIndex { dataSource[indexPath.item].isUserWatchingVideo = false } }
步骤3:修改MyCell,添加播放回调
class MyCell: UICollectionViewCell { var currentItem: Int? var player: AVPlayer = AVPlayer() var onVideoStart: (() -> Void)? // 通知VC播放开始的回调 var video: Video? { didSet { guard let video = video else { return } // 如果是当前播放的索引,跳过初始化逻辑,直接等待VC恢复播放 if currentItem == (parentViewController as? YourViewController)?.currentPlayingIndex { return } // 重置状态并初始化播放 video.isUserWatchingVideo = false guard let url = video.url else { return } let asset = AVAsset(url: url) let playerItem = AVPlayerItem(asset: asset) player.replaceCurrentItem(with: playerItem) player.play() video.isUserWatchingVideo = true onVideoStart?() // 通知VC更新播放索引 } } }
方案3:优化播放触发时机,结合CollectionView可见性
让cell只在真正成为可见单元格时才开始播放,利用willDisplay代理方法代替cell内部的didSet触发播放。
步骤1:修改ViewController的代理方法
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "MyCell", for: indexPath) as! MyCell cell.currentItem = indexPath.item cell.video = dataSource[indexPath.item] // 这里只赋值数据,不触发播放 return cell } func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard let cell = cell as? MyCell, let video = cell.video else { return } if video.isUserWatchingVideo { // 之前已经播放过,直接恢复播放 cell.player.play() } else { // 首次播放,初始化并开始播放 video.isUserWatchingVideo = true guard let url = video.url else { return } let asset = AVAsset(url: url) let playerItem = AVPlayerItem(asset: asset) cell.player.replaceCurrentItem(with: playerItem) cell.player.play() } } func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard let cell = cell as? MyCell else { return } cell.player.pause() dataSource[indexPath.item].isUserWatchingVideo = false }
步骤2:简化MyCell的代码
class MyCell: UICollectionViewCell { var currentItem: Int? var player: AVPlayer = AVPlayer() var video: Video? // 仅作为数据存储,不处理播放逻辑 }
这样,reloadData()时,只有真正即将显示的单元格才会触发willDisplay,当前正在播放的单元格如果依然可见,会直接恢复播放,不会重新初始化,避免中断。
内容的提问来源于stack exchange,提问作者Lance Samaria

