iOS相冊(cè)Moment功能的優(yōu)化方案
最近在開(kāi)發(fā)公司產(chǎn)品Perfect365的Gallery模塊, 包括按日期排序的Moment以及Album這兩個(gè)模塊. Moment功能和系統(tǒng)相冊(cè)類(lèi)似, 就是根據(jù)圖片的日期信息進(jìn)行排序, 然后按照不同日期分section顯示.
Moment的實(shí)現(xiàn)思路很簡(jiǎn)單: 先遍歷系統(tǒng)的所有相冊(cè), 然后獲取每個(gè)相冊(cè)內(nèi)圖片的日期信息, 根據(jù)日期進(jìn)行分類(lèi)和排序, ***把枚舉完的所有數(shù)據(jù)放到界面上來(lái)顯示。示例代碼如下:
- NSSortDescriptor *sort = [NSSortDescriptor sortDescriptorWithKey:@"date" ascending:NO];
- [objects sortUsingDescriptors:@[sort]];
- MomentCollection *lastGroup = nil; NSMutableArray *ds = [[NSMutableArray alloc] init];
- for (ALAsset *asset in objects)
- {
- @autoreleasepool
- {
- NSDateComponents *components = [[NSCalendar currentCalendar] components:NSDayCalendarUnit |
- NSMonthCalendarUnit |
- NSYearCalendarUnit
- fromDate:[asset date]];
- NSUInteger month = [components month];
- NSUInteger year = [components year];
- NSUInteger day = [components day];
- if (!lastGroup || lastGroup.year!=year || lastGroup.month!=month || lastGroup.day!=day)
- {
- lastGroup = [MomentCollection new]; [ds addObject:lastGroup];
- lastGroup.month = month; lastGroup.year = year; lastGroup.day = day;
- }
- ALAsset *lPhoto = [lastGroup.assetObjs lastObject];
- NSURL *lPhotoURL = [lPhoto valueForProperty:ALAssetPropertyAssetURL];
- NSURL *photoURL = [asset valueForProperty:ALAssetPropertyAssetURL];
- if (![lPhotoURL isEqual:photoURL])
- {
- [lastGroup.assetObjs addObject:asset];
- }
- }
- }
So far so good, 接下來(lái)創(chuàng)建UICollectionView, 設(shè)置好dataSource就可以顯示moment圖片了. 起初我也是這么認(rèn)為的, 但是對(duì)于開(kāi)發(fā)一款擁有6500萬(wàn)用戶(hù)的App來(lái)說(shuō)everything is possible. 版本發(fā)布之后, 很多用戶(hù)反饋打開(kāi)相冊(cè)后App直接freeze掉, what the hell is this? QA測(cè)試時(shí)一切OK的呀. 好吧, 繼續(xù)騷擾用戶(hù)詢(xún)問(wèn)到底是神馬情況, 用戶(hù)回復(fù): 我手機(jī)里面放了30k+圖片, 占了20G+的存儲(chǔ)空間. OH MY GOD!!!
優(yōu)化方案
對(duì)于Moment功能, 肯定需要遍歷完系統(tǒng)內(nèi)的所有相冊(cè)圖片, 然后再按日期排序后顯示給用戶(hù), 那優(yōu)化就只能在枚舉和排序這兩部分來(lái)壓榨了. 經(jīng)過(guò)2天的苦思冥想決定采用分批加載+取尾排序的方案來(lái)優(yōu)化. 具體思路為: 如果用戶(hù)設(shè)備內(nèi)的圖片比較多, 不是等所有圖片都枚舉排序完了再顯示, 而是枚舉每隔一定數(shù)量的圖片(e.g. 50張)后就拋出去(放到NSOperationQueue里)按日期分類(lèi)并排序, 再顯示給用戶(hù), 這樣讓用戶(hù)看到我們動(dòng)態(tài)加載圖片的過(guò)程, 讓他知道我們的程序still alive, 并且在不斷的加載圖片. 但是一般情況下排序的耗時(shí)會(huì)大于圖片的枚舉, 也就是***個(gè)50張排完序后, 前面枚舉放到Queue里面等待排序的已經(jīng)有好幾批了, 那么我們只對(duì)***一批的圖片再排序(也就是取尾)并清空當(dāng)前的Queue, 因?yàn)橹虚g的幾批數(shù)據(jù)已經(jīng)makes no sense了. 方案詳細(xì)流程圖如下:
曲線流程圖
為了***程度的減輕動(dòng)態(tài)加載后刷新顯示對(duì)用戶(hù)造成的突兀感, 在顯示之前需要判斷用戶(hù)是否在滑動(dòng)頁(yè)面, 只有頁(yè)面靜止的時(shí)候刷新顯示. 但對(duì)于全部圖片枚舉完成后的***一批數(shù)據(jù)則要暫時(shí)保存住(否則就木有東東顯示了), 待用戶(hù)停止滑動(dòng)后reloadData.
分批加載
Moment需要按日期分類(lèi)顯示(***的顯示在最前面), 所以在枚舉相冊(cè)的時(shí)候可以先從camera roll開(kāi)始(一般用戶(hù)拍攝的照片相對(duì)導(dǎo)入的圖片會(huì)早一點(diǎn)). 加載到50的倍數(shù)張后就拋到queue里面等待排序, 一個(gè)相冊(cè)枚舉完后再繼續(xù)遍歷其余的相冊(cè)...
- - (void)getPhotosWithGroupTypes:(ALAssetsGroupType)types
- batchReturn:(BOOL)batch
- completion:(void (^)(BOOL ret, id obj))completion
- {
- self.batchBlock = completion;
- NSMutableArray *tmpArr = [[NSMutableArray alloc] init];
- [self.assetLibary enumerateGroupsWithTypes:types
- usingBlock:^(ALAssetsGroup *group, BOOL *stop)
- {
- if (self.stopEnumeratePhoto) {*stop = YES; return;}
- NSInteger gType = [[group valueForProperty:ALAssetsGroupPropertyType] integerValue];
- if (group && (gType != ALAssetsGroupPhotoStream))
- {
- [group setAssetsFilter:[ALAssetsFilter allPhotos]];
- [group enumerateAssetsWithOptions:NSEnumerationReverse
- usingBlock:^(ALAsset *result, NSUInteger index, BOOL *stop)
- {
- if (self.stopEnumeratePhoto) {*stop = YES; return;}
- if (result) [tmpArr addObject:result];
- if (batch && !([tmpArr count]%50)) [self addQueueWithData:tmpArr final:NO];
- }];
- }
- else if (nil == group)
- {
- [self addQueueWithData:tmpArr final:YES];
- }
- }failureBlock:nil];
- }
取尾排序
每組批次的圖片都加到一個(gè)串行queue隊(duì)列里面等待排序, 某個(gè)批次的排序完成之后取當(dāng)前queue***一個(gè)(也就是***過(guò)來(lái)的枚舉圖片)繼續(xù)執(zhí)行排序, 并清空當(dāng)前的queue. 也就是在下面的sortMomentWithDate:final:函數(shù)里面調(diào)用cleanQueueAfterRoundOperation.
- - (void)addQueueWithData:(NSMutableArray *)data final:(BOOL)final
- {
- NSMutableArray *rawData = [NSMutableArray arrayWithArray:data];
- NSBlockOperation *op = [NSBlockOperation blockOperationWithBlock:^
- {
- [self sortMomentWithDate:rawData final:final];
- }];
- [self.operQueue addOperation:op];
- }
- - (void)cleanQueueAfterRoundOperation
- {
- if (self.operQueue == nil) return;
- if (self.operQueue.operationCount > 1)
- {
- NSArray *queueArr = self.operQueue.operations;
- NSMutableArray *opArr = [NSMutableArray arrayWithArray:queueArr];
- [opArr removeLastObject]; [opArr removeLastObject];
- [opArr makeObjectsPerformSelector:@selector(cancel)];
- }
- }
刷新CollectionView顯示圖片
中間批次按日期分類(lèi)過(guò)的數(shù)據(jù)ready后, 在reloadData之前先判斷一下當(dāng)前用戶(hù)是否在滑動(dòng)collectionView, 如果是非scroll狀態(tài)則刷新顯示, 否則直接drop掉, 但是對(duì)于***一批數(shù)據(jù)需要先存儲(chǔ)著, 并在scrollViewDidEndDragging和scrollViewDidEndDecelerating里面判斷, 一旦用戶(hù)停止滑動(dòng)了就立即刷新到collectionView上.
- [[ImageDataAPI sharedInstance] getMomentsWithBatchReturn:YES
- ascending:NO
- completion:^(BOOL done, id obj)
- {
- NSMutableArray *dArr = (NSMutableArray *)obj;
- if (dArr != nil && [dArr count])
- {
- if (!self.momentView.dragging && !self.momentView.decelerating)
- {
- dispatch_async(dispatch_get_main_queue(), ^
- {
- [self reloadWithData:dArr];
- });
- }
- else
- {
- if (done) {self.backupArr = dArr}
- }
- }
- }];
- - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
- {
- if (!decelerate && self.backupArr)
- {
- dispatch_async(dispatch_get_main_queue(), ^{
- [self reloadWithData:self.backupArr];
- self.backupArr = nil; // done refresh
- });
- }
- }
- - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
- {
- if (self.backupArr)
- {
- dispatch_async(dispatch_get_main_queue(), ^{
- [self reloadWithData:self.backupArr];
- self.backupArr = nil; // done refresh
- });
- }
- }
后續(xù)改進(jìn)思路
按上述方案不管設(shè)備圖片有多少, 基本可以正常打開(kāi)相冊(cè)并加載圖片. 但還是有很多需要繼續(xù)改進(jìn)的地方: e.g.
-
中間批次的數(shù)據(jù)ready后也可以先存儲(chǔ)著, 待用戶(hù)停止滑動(dòng)后在reload上去, 而不是簡(jiǎn)單的drop掉.
-
排序還需要再優(yōu)化. 現(xiàn)在***批50張圖片排序后, 第二批進(jìn)入排序的200張圖片又需要重新分類(lèi)排序, 中間批次數(shù)據(jù)只是為了先顯示給用戶(hù)看. 是不是第200張圖片可以只對(duì)后面的150張進(jìn)行排序, 也就是后面150張有新的日期, 則新建section, 相同日期直接insert到前面去. 這個(gè)還需要后面再研究...
以上只是自己的一些優(yōu)化思路, 如果有更好的方案, 歡迎留言交流~~
Photos.framework
iOS 8新引入了全新的PhotoKit API, 用來(lái)替代AssetsLibrary框架, PhotoKit提供了直接訪問(wèn)Moment數(shù)據(jù)的接口+ (PHFetchResult
- PHFetchOptions *options = [[PHFetchOptions alloc] init];
- options.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"endDate"
- ascending:ascending]];
- PHFetchResult *momentRes = [PHAssetCollection fetchMomentsWithOptions:options];
- NSMutableArray *momArray = [[NSMutableArray alloc] init];
- for (PHAssetCollection *collection in momentRes)
- {
- NSDateComponents *components = [[NSCalendar currentCalendar] components:NSDayCalendarUnit |
- NSMonthCalendarUnit |
- NSYearCalendarUnit
- fromDate:collection.endDate];
- NSUInteger month = [components month];
- NSUInteger year = [components year];
- NSUInteger day = [components day];
- MomentCollection *moment = [MomentCollection new];
- moment.month = month; moment.year = year; moment.day = day;
- PHFetchOptions *option = [[PHFetchOptions alloc] init];
- option.predicate = [NSPredicate predicateWithFormat:@"mediaType = %d", PHAssetMediaTypeImage];
- moment.assetObjs = [PHAsset fetchAssetsInAssetCollection:collection
- options:option];
- if ([moment.assetObjs count]) [momArray addObject:moment];
- }
So, 我們可以對(duì)外統(tǒng)一moment接口, 在內(nèi)部(Gallery Model類(lèi))區(qū)分系統(tǒng)實(shí)現(xiàn): iOS 7系統(tǒng)采用AssetsLibrary并使用上文的優(yōu)化方案, iOS 8系統(tǒng)則直接調(diào)用Photos.framework的Moment接口.
但是這里面有個(gè)問(wèn)題, AssetsLibrary的類(lèi)型是ALAssetsGroup, 而PhotoKit的類(lèi)型是PHFetchResult, 怎么在使用的時(shí)候統(tǒng)一呢? 難道還需要在外部調(diào)用的時(shí)候再區(qū)分一下系統(tǒng)么?
解決方法很簡(jiǎn)單, 定義自己的數(shù)據(jù)類(lèi), 在數(shù)據(jù)結(jié)構(gòu)內(nèi)部再區(qū)分, 外部調(diào)用時(shí)使用的都是自己定義的數(shù)據(jù)類(lèi)型:
e.g. 定義MomentCollection, 包括年月日信息, 對(duì)外的屬性assetObjs則在內(nèi)部區(qū)分系統(tǒng)并返回或設(shè)定相應(yīng)的類(lèi)型:
- @interface MomentCollection : NSObject
- @property (nonatomic, readwrite) NSUInteger month;
- @property (nonatomic, readwrite) NSUInteger year;
- @property (nonatomic, readwrite) NSUInteger day;
- @property (nonatomic, strong) id assetObjs;
- @end
- @property (nonatomic, strong) NSMutableArray *items;
- @property (nonatomic, strong) PHFetchResult *assets;
- - (id)assetObjs
- {
- return IS_IOS_8 ? self.assets : self.items;
- }
- - (void)setAssetObjs:(id)assetObjs
- {
- if (IS_IOS_8)
- {
- self.assets = (PHFetchResult *)assetObjs;
- }
- else
- {
- self.items = (NSMutableArray *)assetObjs;
- }
- }
對(duì)于相冊(cè)或者某個(gè)具體的圖片也是類(lèi)似的處理方法, 定義AlbumObj和PhotoObj數(shù)據(jù)類(lèi)型. 這樣外界(調(diào)用者)就不用管數(shù)據(jù)類(lèi)型了, 所有的邏輯都在內(nèi)部handle了...
另外, 對(duì)于對(duì)于其他功能, 比如相冊(cè)的枚舉, 相冊(cè)Poster圖片的獲取, 圖片URL的獲取, 某個(gè)相冊(cè)內(nèi)所有thumbnail的獲取等等都可以對(duì)外統(tǒng)一接口, 內(nèi)部再區(qū)分是使用PhotoKit還是AssetsLibrary.
- - (void)getMomentsWithBatchReturn:(BOOL)batch // batch for iOS 7 only
- ascending:(BOOL)ascending
- completion:(void (^)(BOOL done, id obj))completion;
- - (void)getThumbnailForAssetObj:(id)asset
- withSize:(CGSize)size // size for iOS 8 only
- completion:(void (^)(BOOL ret, UIImage *image))completion;
- - (void)getURLForAssetObj:(id)asset
- /*usingPH:(BOOL)usingPH*/
- completion:(void (^)(BOOL ret, NSURL *URL))completion;
- - (void)getAlbumsWithCompletion:(void (^)(BOOL ret, id obj))completion;
- - (void)getPosterImageForAlbumObj:(id)album
- completion:(void (^)(BOOL ret, id obj))completion;
- - (void)getPhotosWithGroup:(id)group
- completion:(void (^)(BOOL ret, id obj))completion;
- - (void)getImageForPhotoObj:(id)asset
- withSize:(CGSize)size
- completion:(void (^)(BOOL ret, UIImage *image))completion;
完整的moment優(yōu)化方案和PhotoKit/AssetsLibrary集成接口實(shí)現(xiàn)代碼(RJPhotoGallery)已經(jīng)上傳到GitHub, 有興趣的童鞋可以參考一下. 程序內(nèi)封裝的ImageDataAPI是圖片加載的model類(lèi), 實(shí)現(xiàn)了Moment/Album功能, 有需要的可以直接copy過(guò)去使用.
P.S. 歡迎各路童鞋大神吐槽和交流~~