PhotosKit開發總結(一)

PJHubs發表於2018-12-21

這個元件做的實在是太久了,最近終於從一大堆事兒中慢慢的恢復過來了,繼續肝!

前言

這次的元件開發換了個思路繼續精進,也還是 MVC 的模式,前段時間自己非常糾結到底哪種模式才是“最佳”設計模式?翻閱了大量資料,後來在這篇文章中得到了“救贖”,讓我真正的從迴歸到從實際問題出發,而不是一昧的為了“用”而用,尤其是在昨天的迭代總結會上,android 同學“誇誇其談”的列出了許多所謂的“優化點”,某些“優化點”在我看來卻是十分的可笑,本來以為來了個大佬,現在看來卻是個“大佬”。

從 11 月末開始就著手準備開發這一新元件,但因為剛好與三方、新版本迭代期、期末課設等各種因素導致元件開發一再延後。該元件利用了 PhotosKit 框架,完成了從系統相簿讀取並自定義相簿的功能,設計稿如下;

設計稿

思考

一開始看到設計圖後,感覺並沒有多少東西需要去做,玩好 PhotosKit 即可,經過了一段時間後,再三確認後,最終的產品效果是要對齊 Instagram 裡的“照片瀏覽器”體驗一致,截圖如下所示:

Instagram 照片瀏覽器截圖

接著我就去玩了 Instagram,越玩越感覺這是個“大坑”,如果要做到 100% 的互動相似,估計做完直接丟出去開源又會拉到一波 star,對 Instagram 的”照片瀏覽器“分析如下:

  • 可以簡單的進行上下拆分。上部分為”選中檢視“,可以直接套 UIImageView,下部分為“瀏覽檢視”,可用 UICollectionView
  • 當”瀏覽檢視“進行“上滑”操作時,無論滑動多麼快速都不會觸發“選中檢視”的連帶“上滑”操作;
  • 當使用者從“瀏覽檢視”的範圍滑動到“選中檢視”中時,也就是手指觸控區域到達“選中檢視”區域,將觸發“選中檢視”的連帶“上滑”操作。
  • 當“選中檢視”已經到頂時,使用者從“瀏覽檢視”進行”下拉“操作,直接觸發“選中檢視”的連帶“下拉”操作。

以上是目前總結出 Instagram 的“照片瀏覽器”四大要點,後續的開發工作也圍繞著這四點進行。

需求拆分

思考環節明確了該元件的開發難點後,開始對需求進行拆分,細緻工作量。前幾天還冒出了一個“笑話”,元件都快開發完了,自己卻不放心,多嘴再去溝通了一遍,發現原來最終的效果和目前所實現的差距有些大,不得不又反工重來。

經過一番調研,設定的耗時為:2天,包括前後端聯調。整理出的需求大致如下:

Feature UI Power
瀏覽相簿 UITableView 0.5
瀏覽相簿照片 UICollectionView 1
互動 - 1

實現

一開始在資料來源的獲取上就跪了,之前在讀取系統相簿資源這方面需求僅僅只是“獲取”這一方面,並沒有對互動有太多的要求,也就一直沒有精進,這回需要對相簿做一個自定義就跪在資料來源的獲取上了,構思後,覺得有必要拉出一層 DataManager,雖然只是從系統中拉資料,但為了“高內聚、低耦合”的理念,應該降低“呼叫方”的使用成本。

挑出了一部分 PhotosKit 框架核心知識點列舉如下:

  • PHObjectPhotos 的資源集合和集合列表的抽象父類;
  • PHAssetCollection:一個相簿;
  • PHCollectionList:一個包含多個相簿的集合;
  • PHFetchResult
    • 作為 PHAsset(Live Photo)、PHCollectionPHAssetCollectionPHCollectionList 相關方法的返回結果物件;
    • 內容可動態載入,並不是直接把某個相簿中的內容直接全部遍歷出來,而是當需要一部分內容後才會去照片庫中獲取,這可以在處理大量結果時提供一個最佳效能;
    • 預設執行緒安全
    • 快取規則個人看法是利用了 LRU,但實際上是不是這麼一回事有待考證。

讀取所有所需相簿

加了個關鍵詞——“所需”,PhotosKit 框架提供了一套十分完整的獲取不同型別相簿 API,在 PJAlbumDataManager 中,我是這麼做的:

/// 獲取所有相簿
private func allAlbumCollection() -> [PHAssetCollection] {
    var collections = [PHAssetCollection]()
    let smartAlbums = PHAssetCollection.fetchAssetCollections(with: .smartAlbum,
                                                              subtype: .any,
                                                              options: nil) as! PHFetchResult<PHCollection>
    let userAlbums = PHCollectionList.fetchTopLevelUserCollections(with: nil)
    
    for album in [smartAlbums, userAlbums] {
        for s_i in 0..<album.count {
            let collection = album[s_i] as! PHAssetCollection
            let types: [PHAssetCollectionSubtype] = [.albumRegular, // 使用者自己建立的相簿
                                                      .smartAlbumPanoramas, // 全景
                                                      .smartAlbumScreenshots, // 螢幕截圖
                                                      .smartAlbumUserLibrary, // 相機膠捲
                                                      .smartAlbumRecentlyAdded] // 最近新增
            if types.contains(collection.assetCollectionSubtype) {
                collections.append(collection)
            }
        }
    }
    return collections
}
複製程式碼

從上文中列出來的核心知識點,明確了 PHAssetCollection 相當於是一個相簿,我們需要拿到由相機拍攝的照片集 .smartAlbum 和使用者自己建立的照片集 fetchTopLevelUserCollections,注意,這包括了“獲取相簿許可權”的應用自行建立的相簿。

albumPHAssetResult<PHAssetCollection> 型別,且因未實現 Sequence 協議,而無法進行遍歷,而採取了一個簡單的做法,至於比較優雅的做法暫未實現。

獲取相簿封面

通過以上方法,我們就拿到了當前使用者裝置中的所有所需相簿集合。那如何獲取一個相簿的封面以及其所包含的照片數呢?經過一番研究後發現 PHAssetCollection 中提供了獲取並未提供“封面”這個屬性,同時也沒有提供單獨的 API 去獲取一個相簿封面,但是通過一個比較尷尬的方法,即通過獲取相簿中的所有照片 API 去鎖定第一張照片,直接作為封面 ?,PJAlbumDataManager 中的實現如下:

/// 獲取所有相簿封面及照片數
func getAlbumCovers(complateHandler: @escaping (_ coverPhotos: [Photo], _ albumPhotosCounts: [Int]) -> Void) {
    let albumCollections = albums
    var photos = [Photo]()
    var photosCount = [Int]()
    // 獲取單張照片資源是非同步過程,需要等待所有相簿的封面圖片一起 append 完後再統一通過逃逸閉包進行返回
    var c_index = 0
    for collection in albumCollections {
        let assets = albumPHAssets(collection)
        // 有些系統自帶相簿型別如果使用者沒有進行照片歸類則會導致取到的相片數為0
        guard assets.count != 0 else {
            c_index += 1
            continue
        }
        
        photosCount.append(assets.count)
        
        var photo = Photo()
        photo.photoTitle = collection.localizedTitle
        convertPHAssetToUIImage(asset: assets[0],
                                size: CGSize(width: 150, height: 150),
                                mode: .fastFormat) { (photoImage) in
                                    photo.photoImage = photoImage
                                    photos.append(photo)
                                    c_index += 1
            
                                    if c_index == albumCollections.count - 1 {
                                        complateHandler(photos, photosCount)
                                    }
        }
    }
}
複製程式碼

同樣在上文中我們也說明了,一張張的照片是 PHAsset 資源物件,而 PHAsset 是從 PHAssetCollection 取出的,並且取出的資源集合型別中不需要包含視訊且按照時間“由近到遠”對集合進行排序。在 iOS 中對集合進行檢索最佳做法是通過**“謂詞”**進行限制,PJAlbumDataManager 實現 albumPHAssets 方法如下所示:

/// 當前相簿的所有 PJAsset
private func albumPHAssets(_ collection: PHAssetCollection) -> PHFetchResult<PHAsset> {
    let options = PHFetchOptions()
    options.predicate = NSPredicate(format: "mediaType = %d", PHAssetMediaType.image.rawValue)
    options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
    let fetchResult = PHAsset.fetchAssets(in: collection, options: options)
    return fetchResult
}
複製程式碼

一個相簿中的所有 PHAsset 資源是全都拿到了,但是 PHAsset 資源卻無法直接與 UIKit 進行協作,還需要對 PHAsset 物件轉為 UIImage 物件,PJAlbumDataManager 中是這麼做的:

/// PHAsset 轉 UIImage
func convertPHAssetToUIImage(asset: PHAsset,
                                      size: CGSize,
                                      mode: PHImageRequestOptionsDeliveryMode,
                                      complateHandler: @escaping (_ photo: UIImage?) -> Void) {
    let coverSize = size
    let options = PHImageRequestOptions()
    options.isSynchronous = false
    options.deliveryMode = mode
    options.isNetworkAccessAllowed = true
    PHImageManager.default().requestImage(for: asset,
                                          targetSize: coverSize,
                                          contentMode: .default,
                                          options: options) { result, info in
                                            guard result != nil else { return complateHandler(nil) }
                                            complateHandler(result)
    }
}
複製程式碼

requestImage 方法中,我們可以對最終要生成 UIImage 物件做一些額外的設定,例如“目標尺寸”、“是否非同步操作”等。需要注意的是,如果開啟了“非同步操作”,要記得處理好全部 PHAsset 資源集合的非同步獲取時間節點,否則一張照片都拿不到。

UI 搭建

顯示所有相簿的封面及其照片數,上文解決了資料來源後,剩下的事情就正常操作 UITableView 即可,完工截圖如下所示:

選擇相簿 截圖

獲取一個相簿下的所有照片

這部分思路已經上節中描述過了,把“只取第一張的照片”限制條件放開即可,PJAlbumDataManager 的實現如下:

/// 獲取某一相簿的所有照片
func getAlbumPhotos(albumCollection: PHAssetCollection,
                    complateHandler: @escaping (([Photo], PHFetchResult<PHAsset>) -> Void)) {
    let assets = albumPHAssets(albumCollection)
    var photos = [Photo]()

    for a_index in 0..<assets.count {
        var photo = Photo()
        convertPHAssetToUIImage(asset: assets[a_index],
                                size: CGSize(width: 150, height: 150),
                                mode: .highQualityFormat) { (photoImage) in
                                    photo.photoImage = photoImage
                                    photo.photoTitle = albumCollection.localizedTitle ?? ""
                                    photos.append(photo)
            
                                    if a_index == assets.count - 1 {
                                        complateHandler(photos, assets)
                                    }
        }
    }
}
複製程式碼

UI 搭建

在相簿詳情的 UI 搭建中,主要涉及到有 UICollectionViewUIImageView 兩者,但在互動上需要滿足上文所說的這三點:

  • 當”瀏覽檢視“進行“上滑”操作時,無論滑動多麼快速都不會觸發“選中檢視”的連帶“上滑”操作;
  • 當使用者從“瀏覽檢視”的範圍滑動到“選中檢視”中時,也就是手指觸控區域到達“選中檢視”區域,將觸發“選中檢視”的連帶“上滑”操作。
  • 當“選中檢視”已經到頂時,使用者從“瀏覽檢視”進行”下拉“操作,直接觸發“選中檢視”的連帶“下拉”操作。

這三點中實現難度相對較高的為第二點,需要利用 hitTestpoint(inside) 進行實現,這部分放到下篇文章中結合前後端互動一塊進行分享。目前實現的部分效果截圖如下:

相簿詳情 截圖

優化點

  • 相簿讀取因為採用的是“計算屬性”,並不會對已經讀取過的資料結果進行快取,應採用 lazy
  • 互動細節需要繼續完善。

原文地址:PJ 的 iOS 開發日常

相關文章