推薦語:這篇文章從系統權限、API 調用、架構設計等角度,生動演示了一個設計友好、模塊獨立、易拓展以及用戶體驗優秀的相冊是如何開發出來的。除此之外,作者針對各種小細節也做了優化和解析,使得功能實現更加的豐滿。文章整體讀下來,可以讓讀者對于相冊的設計和開發有深刻的印象,具備極大的指導意義,推薦閱讀!
——大淘寶技術終端開發工程師 雋弦
前言
拍立淘相冊作為拍立淘業務的基礎功能,已經在線上運行多年,它同時支撐了拍立淘、掃一掃、AR試等業務的相簿及相冊的展示和選擇能力。在業務不斷發展的過程中,相冊中增加的業務代碼也不斷增多,造成了隱含的問題,如模塊化能力與業務代碼耦合、架構分層不清晰導致維護成本高等。本文基于拍立淘相冊在發展中遇到的問題,對相冊的整體架構設計和實現思路進行總結闡述,并針對相冊功能開發時會遇到的共性問題提供了解決方案。
背景與挑戰
在拍立淘業務中,核心原子能力之一是相冊模塊,早期相冊模塊存在如下的問題:
- 相冊存在架構問題,相冊與業務代碼耦合、沒有分層設計、牽一發動全身,作為一個相對獨立的模塊,不利于整體遷移和維護。
- 存在業務邏輯問題,隨著業務不斷發展,散落的邏輯補丁沒有收口導致維護成本高。
- 存在用戶體驗問題,如對iOS14以上部分權限授權的引導做的不夠,在相冊場景里面無法實時刷新,UI不適配不統一如對劉海屏、靈動島的適配和相冊不同tab之間的列表間距不一致問題等。
同時,對相冊的重點訴求如下:
- 需要增加相冊外露能力,相冊外露能力的底層鏈路應和相冊本體保持復用。
- 將當前的相冊模塊化,使用模塊接入的形式調用,不直接依賴拍立淘的業務代碼;理想情況能夠剝離成獨立的SDK。
- 能夠提供給業務方使用,包括拍立淘、掃一掃等
- 相冊視覺煥新,包括增加統一的圖片背景,整體色調更換等。
架構設計與實現
根據相冊的功能,現在先把相冊框架自頂向下拆解:
接口層:相冊的對外接口(適配層、API)
視圖定制層:相冊的展示鏈路(MVVM架構)
邏輯管理層:
- 相冊的讀取鏈路(包括讀取 Asset 和獲取源文件)
- 相冊的變更鏈路(觀察者模式)
- 相冊的體驗優化(預加載、緩存回收)
根據上述的分層思想,依次自底向上設計與實現
? 讀取鏈路
在制作相冊管理中,讀取圖片是重要的一個環節。設計一個相冊讀取管理器,主要功能:
- 通過 API 獲取系統/用戶相簿
- 查詢相簿內容
- 相簿模型轉換
- 讀取多媒體元素
在讀取管理器的 API 的設計上,封裝了讀取操作的細節,只需要提供獲取相冊數據接口以及上層可以設置的查詢配置屬性就好就好。
? 變更**鏈路**
相冊變更鏈路是因業務場景而產生的訴求,主要解決的問題是:用戶停留在相冊頁面時候,如果系統相冊發生變更(如截屏、錄屏、切后臺時候進行相冊操作),需要刷新頁面。
這里有個優化點,系統相冊提供的獲取相冊變更數據的能力,類似于游標增量讀取。即給定一個游標,API查詢基于這個游標之后的變更。那么在相冊場景中,這個游標就是 <span style="font-size: 15px;letter-spacing: 1px;">PHFetchResult
,具體來說,就是讀取管理器獲取相簿數據之后拿到的。
讀取管理器對「游標」和「觀察者」能力的補充,示例代碼如下:
- (NSArray <PhotoGroupModel *> *)fetchAssetGroup {
//...
//讀取相簿數據 `-_fetchSystemAssetGroup`
//保存游標
self.lastResult = assetGroups.lastObject.fetchResult;
//開啟觀察者
[self startObserver];
//...
}
- (void)startObserver {
[self.observer initCursor:self.lastResult];
[self.observer startObserver];
}
- (void)stopObserver {
[self.observer stopObserver];
}
觀察者的注冊和反注冊如下:
- (void)startObserver {
//拋到指定線程處理
dispatch_async(self.observerQueue, ^{
//防止重復注冊
if(!self.isObserving) {
//前后臺切換,使用的全量更新
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(resumeForeground)
name:UIApplicationWillEnterForegroundNotification
object:nil];
self.isObserving = YES;
//注冊觀察者
[[PHPhotoLibrary sharedPhotoLibrary] registerChangeObserver:self];
}
});
}
- (void)stopObserver {
//拋到指定線程處理
dispatch_async(self.observerQueue, ^{
//防止重復反注冊
if(self.isObserving) {
self.isObserving = NO;
[[NSNotificationCenter defaultCenter] removeObserver:self];
//反注冊觀察者
[[PHPhotoLibrary sharedPhotoLibrary] unregisterChangeObserver:self];
}
});
}
簡單的來說,觀察者注冊后,有回調- (void)photoLibraryDidChange:(PHChange *)changeInstance;
后就更新數據,通知頁面刷新就可以了。但這里會遇到衍生問題:
- iOS14 部分相冊權限時,回調的數據有不同。解法:針對 iOS14 有限選擇照片的判斷及通知
- 變更回調會發生多次,如果不進行過濾,會造成無效讀取/刷新。解法:只有RIC - removedIndexes,insertedIndexes,changedIndexes才進行回調
因此,觀察者的實現的流程如下:
關于相冊變更后更精準的刷新:
在 Apple Doc 中提供了一種更為精確的配合CollectionView的刷新方式:根據PHFetchResultChangeDetails
里面的增刪改Set,直接進行BatchUpdates
,因為我們相冊的 View 和 ViewModel 和原始數據不相同,因此沒有采用。感興趣的可以看下列官方示例代碼:
Apple Doc地址 :https://developer.apple.com/documentation/photokit/phfetchresultchangedetails?language=objc
@property (nonatomic, strong) UICollectionView* collectionView;
// ...
PHFetchResultChangeDetails* fetchResultChangeDetails;
NSIndexSet* deletedCollectionIndicesBeforeChanges;
NSIndexSet* insertedCollectionIndicesAfterDeletions;
NSArray* deletedPhotoPathsBeforeChanges;
NSArray* insertedPhotoPathsAfterDeletions;
//精準增刪改:
[self.collectionView performBatchUpdates:^{
if (deletedCollectionIndicesBeforeChanges.count > 0) {
[self.collectionView deleteSections:deletedCollectionIndicesBeforeChanges];
}
if (insertedCollectionIndicesAfterDeletions.count > 0) {
[self.collectionView insertSections:insertedCollectionIndicesAfterDeletions];
}
if (deletedPhotoPathsBeforeChanges.count > 0) {
[self.collectionView deleteItemsAtIndexPaths:deletedPhotoPathsBeforeChanges];
}
if (insertedPhotoPathsAfterDeletions) {
[self.collectionView insertItemsAtIndexPaths:insertedPhotoPathsAfterDeletions];
}
} completion:nil];
[fetchResultChangeDetails.changedIndexes enumerateIndexesUsingBlock:^(NSUInteger index, BOOL* stop) {
NSIndexPath* indexPath = [NSIndexPath indexPathWithItem:index inSection:0];
UICollectionViewCell* changedCell = [self.collectionView cellForItemAtIndexPath:indexPath];
if (changedCell) {
// ... Configure changedCell here ...
}
// Set *stop = YES to stop iteration early.
}];
? 體驗優化
預加載及時機:按中端機來說,平均讀取10000張預估需要1s,按照這種時長,預加載是必要的。但是,預加載和隱私合規是需要權衡的。具體展開會在文章后面「實現細節-相冊隱私合規」中闡述。這里主要引出需要做預加載這個動作。
思路是提供一個單例,在合適的時機,可以操作讀取管理器進行讀取并保存。視圖層在即將展示時候,判斷讀取管理器是否有緩存數據,并且已經完成讀取。則直接使用緩存數據。也因為增加了這個邏輯,需要有讀取完成的回調,防止視圖層判斷到正在讀取時,沒有時機響應數據回調。
? 展示鏈路
展示鏈路上,就是根據視覺需要各顯神通了??紤]到保持底層邏輯復用,對相冊做了MVVM架構,保持了Model、ViewModel、View不變的情況下,支持了相冊外露能力和相冊本體兩個ViewController。
? 對外接口
為了讓相冊模塊獨立,但又需要業務方修改,增加了適配層。我們的適配層提供了如下的能力可以作為參考:
對外API方面,我們進行了可擴展的封裝:
可擴展性體現在:
- 回調參數使用字典,可以靈活擴展,通過Key獲取。
- 創建相冊通過配置相關能力,方便業務方增加需求。
/*
* imagePickerFinishOpenBlock 用戶選擇相冊的圖片或視頻后的回調
* @params mediaInfo 參數說明
* key: ImagePickerInfoKeyMetaInfo vaule: 系統返回的metaInfo (NSDictionary *)
* key: ImagePickerInfoKeyImage value:圖片對象(UIImage *_Nullable)
* key: ImagePickerInfoKeyAvAsset value: 視頻的avAsset(AVAsset *_Nullable)
* key: ImagePickerInfoKeyError value:錯誤信息 (Error *_Nullable)
*/
typedef void (^imagePickerFinishOpenBlock)(NSDictionary *mediaInfo, NSDictionary *paramURLArgs);
typedef void (^imagePickerCancelBlock)(void);
extern const NSString *ImagePickerInfoKeyImage;
extern const NSString *ImagePickerInfoKeyAvAsset;
extern const NSString *ImagePickerInfoKeyMetaInfo;
extern const NSString *ImagePickerInfoKeyError;
#pragma mark - 業務能力
/*
* 打開相冊,選擇相片之后回調,此時使用默認的photoConfig配置
* @param getImageBlock 獲取到圖片/視頻后的回調
* @param cancelBlock 用戶取消block,相冊dismiss前調用
*/
- (void)openImagePickerViewController:(imagePickerFinishOpenBlock)getImageBlock
cancelBlock:(imagePickerCancelBlock)cancelBlock;
/*
* 打開相冊,選擇相片之后回調
* @param config 用戶自定義相冊相關的配置
* @param getImageBlock 獲取到圖片/視頻后的回調
* @param cancelBlock 用戶取消block,相冊dismiss前調用
*/
- (void)openImagePickerViewControllerWithConfig:(PhotoConfig *)photoConfig
finishBlock:(imagePickerFinishOpenBlock)getImageBlock
cancelBlock:(imagePickerCancelBlock)cancelBlock;
/*
* 若用戶未授予權限,則主動申請相冊權限,并打開相冊,選擇相片之后回調
* @param photoConfig 用戶自定義相冊相關的配置
* @param getImageBlock 獲取到圖片/視頻后的回調
* @param cancelBlock 用戶取消block,相冊dismiss前調用
*/
- (void)requestAuthAndOpenImagePickerViewControllerWithConfig:(PhotoConfig *)photoConfig
finishBlock:(imagePickerFinishOpenBlock)getImageBlock
cancelBlock:(imagePickerCancelBlock)cancelBlock;
/*
* 關閉相冊頁
*/
- (void)dismissImagePickerAnimated:(BOOL)flag completion:(void (^)(void))completion;
整體架構
基于上述功能的設計,相冊的基礎架構如下所示:
整體采用分層結構設計,分為:邏輯管理層、視圖定制層、接口層。接口層對外使用代理模式,由業務方實現PhotoAdapter。視圖定制層使用 MVVM 模式。
實現細節
通過上述的設計搭建出相冊的框架和功能,但是還有一些內容是需要開發者關注的。
? App 隱私報告
iOS15.3后蘋果增加了新功能:隱私-APP隱私報告。因此,需要根據自身APP的情況,在最合適的時機進行相冊的讀取。這個讀取問題又分為兩部分:
- 未授權時候,如果進行讀取,就會產生相冊權限申請彈窗。
- 對相冊進行讀取時候,在APP隱私報告中留下記錄。
針對第一個問題,需要做好權限判斷,在最合適的時機申請權限,在所有讀取相冊的時候都進行權限判斷。
針對第二個問題,我們發現除了很明顯的讀取系統相冊之外,注冊相冊變更獲取事件也會讓APP隱私報告中留下記錄。解決方案是在合適的時機注冊相冊變更獲取。
? 關于“有限權限訪問那些事
iOS14 新增了“Limited Photo Library Access” 模式,在授權彈窗中增加了 Select Photo 選項。用戶可以在 App 請求調用相冊時選擇部分照片讓 App 讀取。從 App 的視角來看,你的相冊里就只有這幾張照片,App 無法得知其它照片的存在。
這對于開發者來說,有幾個場景可以優化:
一是注冊相冊變更,詳見「設計與實現-相冊的變更鏈路」中,觀察者對 iOS14 有限選擇照片的判斷及通知的處理
二是當判斷到有限權限的時候,告知用戶,并對用戶引導開啟更多照片權限。
? 支持iCloud選取
系統相冊對存儲大量照片是有優化的,部分照片是放在了iCloud上,這時候,對于API調用不對就會造成讀取不到圖片。在cell進行展示的時候,首先是用縮略圖打底,當用戶點擊圖片后,才進行大圖的加載。加載縮略圖的流程如下:
加載大圖的代碼如下:
? 多線程問題
在實際的操作中,由于很多業務場景都進行了Photos庫的操作,會遇到如下問題:
子線程如果在做耗時讀取操作,并且如果多個子線程同時操作,這時候產生os_unfair_lock_lock。此時,如果主線程對PHPhotoLibrary進行任何操作,大概率造成ANR。
解決方案是:
- 首先我們自己的業務,對相冊的讀取操作,都放到一個指定的線程。
- 其次,進行子線程加載時,如果主線程有訪問PHPhotoLibrary的地方,進行阻塞loading加載。
總結和展望
相冊這塊作為基礎能力,通過本次重構,一方面移除了業務耦合,整體架構清晰,減少了后續的維護成本。目前已有三個場景接入,另一方面對限制使用相冊的場景也產生了比較好的業務效果。在相冊方面,后續會關注數據量巨大情況的加載體驗優化。也歡迎所有小伙伴多用手淘的拍立淘功能~