iOS 實現簡單的列表預載入

Nemocdz發表於2019-02-07

在大部分 App 中,在有 feeds 流之類列表的地方,由於後端資料一般採用分頁載入,為了使用者體驗需要做預載入。最簡單的載入方式,就是當列表顯示的內容達到一定的數量時候,自動請求下一個分頁。

載入策略

而這其實就是根據總行數,列表總高度,列表當前偏移值這三個數字決定是否要載入的關係式 fx。這裡判斷載入的策略,是需要自定義的,所以可以定義這樣一個 Protocol。

protocol ListPrefetcherStrategy {
    var totalRowsCount:Int { get set }
    func shouldFetch(_ totalHeight:CGFloat, _ offsetY:CGFloat) -> Bool
}
複製程式碼

下面給出幾種簡單的載入策略。

閾值策略

設定一個閾值,比如 70%,顯示內容達到閾值時進行載入。這種比較時候每一頁的數量一致的情況。

同時要注意的是,這裡的閾值應該是每個分頁的閾值,總的閾值會隨著列表長度增長。比如設定閾值為 70%,每頁載入 10 個,第一頁在載入到 7 個時進行預載入,第二頁在第 17 個時進行預載入,此時閾值為 85%,而如果還是 70%,則會在第 14 個時進行預載入。所以這裡的閾值需要動態增長。

假設我們已知目前列表的資料量和目前頁數,根據每一頁的閾值就可以動態計算總閾值:

// 資料總數除以當前頁數,算出每一頁的數量
let perPageCount = Double(totalRowsCount) / Double(currentPageIndex + 1)
// 每頁數量乘以頁數加上每一頁的閾值的和,就是總共需要的數量
let needRowsCount = perPageCount * (Double(currentPageIndex) + threshold)
// 算出動態的閾值
let actalThreshold = needRowsCount / Double(totalRowsCount)
複製程式碼

這裡需要記錄當前的頁數,筆者這裡用了一個比較 trick 的做法,當行數增長時,則認為頁數 +1,行數減少時,則認為頁數歸 0,適用於下拉重新整理整個列表清空的情況。可以用屬性觀察 willSet 來改變頁數。

struct ThresholdStrategy: ListPrefetcherStrategy{
    func shouldFetch(_ totalHeight: CGFloat, _ offsetY: CGFloat) -> Bool {
        let viewRatio = Double(offsetY / totalHeight)
        let perPageCount = Double(totalRowsCount) / Double(currentPageIndex + 1)
        let needRowsCount = perPageCount * (Double(currentPageIndex) + threshold)
        let actalThreshold = needRowsCount / Double(totalRowsCount)
        
        if viewRatio >= actalThreshold {
            return true
        } else {
            return false
        }
    }
    
    var totalRowsCount: Int{
        willSet{
            if newValue > totalRowsCount {
                currentPageIndex += 1
            } else if newValue < totalRowsCount {
                currentPageIndex = 0
            }
        }
    }
    
    let threshold: Double
    var currentPageIndex = 0
    
    public init(threshold:Double = 0.7) {
        self.threshold = threshold
        totalRowsCount = 0
    }
}
複製程式碼

剩餘策略

也可以設定當列表剩餘未展示行數即將少於某個值時,進行載入。這種適合每次分頁數量不一定一致的情況。

struct RemainStrategy: ListPrefetcherStrategy{
    func shouldFetch(_ totalHeight: CGFloat, _ offsetY: CGFloat) -> Bool {
        let rowHeight = totalHeight / CGFloat(totalRowsCount)
        let needOffsetY = rowHeight * CGFloat(totalRowsCount - remainRowsCount)
        if offsetY > needOffsetY {
            return true
        } else {
            return false
        }
    }
    
    var totalRowsCount: Int
    let remainRowsCount: Int
    
    
    init(remainRowsCount:Int = 1) {
        self.remainRowsCount = remainRowsCount
        totalRowsCount = 0
    }
}
複製程式碼

除法策略

還可以自己定義除數和餘數,當達到餘數時,進行載入。當然還要考慮一下實際餘數比指定餘數小的情況,這裡筆者簡單的往前面偏移一個除數的量進行判斷。

struct OffsetStrategy: ListPrefetcherStrategy {
    func shouldFetch(_ totalHeight: CGFloat, _ offsetY: CGFloat) -> Bool {
        let rowHeight = totalHeight / CGFloat(totalRowsCount)
        let actalOffset = totalRowsCount % gap
        let needOffsetY = actalOffset > offset ? totalHeight - CGFloat(actalOffset - offset) * rowHeight : totalHeight - CGFloat(2 * gap + offset) * rowHeight
        if offsetY > needOffsetY {
            return true
        } else {
            return false
        }
    }
    
    var totalRowsCount: Int
    let gap: Int
    let offset: Int
    
    init(gap:Int, offset:Int) {
        self.gap = gap
        self.offset = offset
        totalRowsCount = 0
    }
}
複製程式碼

預載入元件

元件需要的資訊有,scrollView,總行數,以及載入時候的通知外界。

定義一個 delegate 讓外界遵循。

protocol ListPrefetcherDelegate:AnyObject {
    var totalRowsCount:Int { get }
    func startFetch()
}
複製程式碼

然後用 KVO 監聽 scrollView 的 contentSize,當發生變化是,就認為總行數發生改變,就可以將總行數設定給策略。監聽 scrollView 的 contentOffset,變化時就是列表滾動,就可以用策略進行判斷。

class ListPrefetcher:NSObject{
    @objc let scrollView:UIScrollView
    var contentSizeObserver:NSKeyValueObservation?
    var contentOffsetObserver:NSKeyValueObservation?
    weak var delegate: ListPrefetcherDelegate?
    var strategy: ListPrefetcherStrategy
    
    public func start() {
        contentSizeObserver = observe(\.scrollView.contentSize) { (_, _) in
            guard let delegate = self.delegate else { return }
            self.strategy.totalRowsCount = delegate.totalRowsCount
        }
        
        contentOffsetObserver = observe(\.scrollView.contentOffset){ (_, _) in
            let offsetY = self.scrollView.contentOffset.y + self.scrollView.frame.height
            let totalHeight = self.scrollView.contentSize.height
            guard offsetY < totalHeight  else { return }
            if self.strategy.shouldFetch(totalHeight, offsetY) {
                self.delegate?.startFetch()
            }
        }
    }
    
    public func stop() {
        contentSizeObserver?.invalidate()
        contentOffsetObserver?.invalidate()
    }
    
    public init(strategy:ListPrefetcherStrategy, scrollView:UIScrollView) {
        self.strategy = strategy
        self.scrollView = scrollView
    }
}

複製程式碼

這樣外界使用起來只需要提供策略和 scrollView,實現 delegate 的方法,然後在需要的時候 start 和 stop,就可以自動完成預載入的工作了。

最後

完整的 Demo,也可以自定義不同的策略實現。

相關文章