iOS开源照片浏览器框架SGPhotoBrowser的设计与实现

简介

近日在制作一个开源加密相册时附带着设计了一个照片浏览器,在进一步优化后发布到了GitHub供大家使用,该框架虽然没有MWPhotoBrowser那么强大,但是使用起来更为方便,操作更符合常规相册习惯,自定义和修改源码也十分简单。
本文主要介绍这个照片浏览器框架的技术要点,如果要深入研究和使用,可以在下面的链接中下载源码。

如果你对这个框架有兴趣,可以点击这里前去GitHub下载源码,欢迎Star与指出不足

效果图

缩略图预览,点击缩略图进入原图浏览,点击底部工具栏可以进入编辑模式。


批量导出与删除,通过底部工具栏操作。

查看原图,单击可以隐藏导航栏和工具栏,支持双击切换缩放状态、捏和手势以及左右滑动切图。

功能与特点

  • block数据源
    照片浏览器的数据源是通过block回调的,通过实现相应的block并且提供数据模型即可完成图片显示。

  • 内存优化
    高分辨率的图片在读入到内存后的内存占用是十分可观的,因此在点击缩略图进入原图浏览后,由于要左右滑动来查看其它图片的原图,因此至少加载三张原图(不考虑边缘情况),分别是当前查看的图片和与之相邻的图片,而其他图片则先加载缩略图,在滚动到那些图片时才去加载原图以及与之相邻的原图,并且替换远处的原图为缩略图。

  • 滚动优化
    在滚动完全结束后才去加载原图并替换缩略图,以防止滚动时卡顿。

  • 同时支持本地与网络图片
    通过URL的类型来判断图片是否来自网络,如果来自网络则异步下载并显示进度,同时进行缓存。

  • 原图浏览时支持常见的手势
    原图浏览器时支持单击隐藏和显示导航栏和工具条,双击在适应屏幕和原始尺寸之间切换,捏和手势可以缩放图片,左右滑动可以切换图片。

  • 支持批量导出与删除照片
    可以通过工具栏进入编辑模式来批量处理图片的导出与删除。

技术要点

概述

照片浏览器框架依赖了SDWebImage和MBProgressHUD,前者用于处理图片的异步下载与缓存,后者用于显示图片下载的进度。用于缩略图显示的是collectionView,查看原图时每一张图片都被均匀排列在scrollView上,每一张图片也被包裹了一个scrollView用于处理缩放。

block数据源

使用代理模式回调数据源会使得代码较为分散,因此本框架使用了block来回调,在SGPhotoBrowser中有四个数据源block,通过实现他们并且提供相应的数据即可完成图片显示,这四个block如下面代码所示。

@property (nonatomic, copy, readonly) SGPhotoBrowserDataSourceNumberBlock numberOfPhotosHandler;
@property (nonatomic, copy, readonly) SGPhotoBrowserDataSourcePhotoBlock photoAtIndexHandler;
@property (nonatomic, copy, readonly) SGPhotoBrowserReloadRequestBlock reloadHandler;
@property (nonatomic, copy, readonly) SGPhotoBrowserDeletePhotoAtIndexBlock deleteHandler;

每个照片通过一个SGPhotoModel数据模型类要描述,其中包含了photoURL与thumbURL,分别代表原图和缩略图的URL,通过URL是否是fileURL来决定是否要异步下载缓存。
block数据源在缩略图浏览时被collectionView的dataSource所调用,在原图浏览时被调用以获取特定位置的图片URL或进行删除照片后的数据刷新。

内存优化

在查看原图时,加载当前位置和与其相邻位置的原图,其他位置均加载缩略图,在滑动过程中,动态的切换原图的加载位置并将原来位置的原图替换为缩略图,以保证内存中最多有三张原图被加载以节省内存,具体实现代码如下。

// 点击index处的缩略图时调用,来显示原图
- (void)loadImageAtIndex:(NSInteger)index {
    // 通过browser的数据源方法获取模型数量
    NSInteger count = self.browser.numberOfPhotosHandler();
    // 遍历所有照片模型以及照片视图
    for (NSInteger i = 0; i < count; i++) {
        SGPhotoModel *model = self.browser.photoAtIndexHandler(i);
        SGZoomingImageView *imageView = self.imageViews[i];
        NSURL *photoURL = model.photoURL;
        NSURL *thumbURL = model.thumbURL;
        // index位置和与其相邻的位置加载原图
        if (i >= index - 1 && i <= index + 1) {
            if (imageView.isOrigin) continue;
            // 根据URL选择图片是直接从本地加载还是异步下载缓存的方法
            [imageView.innerImageView sg_setImageWithURL:photoURL model:model];
            // 用于指示这个imageView是否加载的是原图
            imageView.isOrigin = YES;
            // 缩放至适应屏幕
            [imageView scaleToFitAnimated:NO];
        } else {
            // 对于其他位置的图片,如果是原图,则替换为缩略图
            if (!imageView.isOrigin) continue;
            [imageView.innerImageView sg_setImageWithURL:thumbURL model:model];
            imageView.isOrigin = NO;
            [imageView scaleToFitAnimated:NO];
        }
    }
}

滚动优化

在scrollView的滚动效果尚未停止时进行耗时操作会造成卡顿,为了避免这种情况,可以在scrollView减速完毕后再进行耗时操作。在本框架中,在左右滑动切换图片时,如果立即加载原图,会造成卡顿,因此在scrollView减速完毕后才将缩略图替换为原图,具体实现如下。

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
    // 先通过偏移量计算出当前滚动到的图片的索引
    CGFloat offsetX = scrollView.contentOffset.x;
    NSInteger index = (offsetX + _pageW * 0.5f) / _pageW;
    // 索引发生变化时才更新并加载原图
    if (_index != index) {
        _index = index;
        // 上文提到的加载原图的方法
        [self loadImageAtIndex:_index];
    }
}

本地图片与网络图片的处理

所有的图片都是通过URL进行设置,通过为UIImageView添加分类,并添加方法sg_setImageWithURL:model:方法,传入当前要加载的图片的URL以及照片模型,在方法内,通过URL类型来判断是否要进行异步下载和缓存,在异步下载时,使用MBProgressHUD来指示进度,具体代码如下。

@interface UIImageView (SGExtension)
// 通过动态绑定来实现为UIImageView添加属性
@property (nonatomic, weak) MBProgressHUD *hud;
@property (nonatomic, strong) SGPhotoModel *model;

- (void)sg_setImageWithURL:(NSURL *)url model:(SGPhotoModel *)model;

@end
@implementation UIImageView (SGExtension)
// 动态绑定hud和model两个属性的key
static char hudKey;
static char modelKey;
// 由于分类不允许添加属性,因此需要手动实现setter与getter
@dynamic hud;
@dynamic model;

- (void)sg_setImageWithURL:(NSURL *)url {
    if (![url isFileURL]) {
        // 如果不是文件URL,则说明需要下载,通过SDWebImage处理
        SDImageCache *cache = [SDImageCache sharedImageCache];
        SDWebImageManager *mgr = [SDWebImageManager sharedManager];
        NSString *key = [mgr cacheKeyForURL:url];
        // 如果在缓存中找到了图片,则直接加载并返回
        if ([cache diskImageExistsWithKey:key] || ([cache imageFromMemoryCacheForKey:key] != nil)) {
            [self sd_setImageWithURL:url];
            return;
        }
        // 如果已经有了进度指示器,则说明正在下载图片,直接返回
        if (self.hud != nil) {
            return;
        }
        // 图片需要下载,且任务还未开始,通过MBProgressHUD指示下载进度,通过SDWebImage来下载和缓存图片
        MBProgressHUD *hud = [[MBProgressHUD alloc] initWithView:self];
        self.hud = hud;
        hud.mode = MBProgressHUDModeAnnularDeterminate;
        [self addSubview:hud];
        [hud showAnimated:YES];
        // 如果对应于当前原图的缩略图已经下载完成,则先在原图浏览中显示缩略图作为占位图,否则显示默认的黑色图片。
        UIImage *placeHolderImage = [UIImage imageWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"SGPhotoBrowser.bundle/ImagePlaceholder.png" ofType:nil]];
        if (self.model.thumbURL) {
            NSString *key = [mgr cacheKeyForURL:self.model.thumbURL];
            UIImage *tempImage = [cache imageFromMemoryCacheForKey:key];
            if (tempImage == nil) {
                tempImage = [cache imageFromDiskCacheForKey:key];
            }
            if (tempImage) {
                placeHolderImage = tempImage;
            }
        }
        [self sd_setImageWithURL:url placeholderImage:placeHolderImage options:SDWebImageRetryFailed progress:^(NSInteger receivedSize, NSInteger expectedSize) {
            hud.progress = (float)receivedSize / expectedSize;
        } completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, NSURL *imageURL) {
            [hud removeFromSuperview];
            self.hud = nil;
        }];
    } else {
        // 对于文件URL,直接从文件系统中加载
        self.image = [UIImage imageWithContentsOfFile:url.path];
    }
}
// 公共方法,由于占位图相关逻辑需要缩略图URL,因此需要传递model,上面的方法为私有方法
- (void)sg_setImageWithURL:(NSURL *)url model:(SGPhotoModel *)model {
    self.model = model;
    [self sg_setImageWithURL:url];
}
// 动态绑定的两属性的getter和setter
#pragma mark - Setter
- (void)setHud:(MBProgressHUD *)hud {
    objc_setAssociatedObject(self, &hudKey, hud, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (void)setModel:(SGPhotoModel *)model {
    objc_setAssociatedObject(self, &modelKey, model, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

#pragma mark - Getter
- (MBProgressHUD *)hud {
    return objc_getAssociatedObject(self, &hudKey);
}

- (SGPhotoModel *)model {
    return objc_getAssociatedObject(self, &modelKey);
}
@end

原图浏览时的手势处理

每张图片使用一个scrollView包裹来处理捏合手势缩放,同时通过touchesEnded::方法来判断单击和双击,由于双击时会经过单击状态,这里将单击事件滞后0.2s处理,如果在这期间触发了双击,则取消单击事件的处理,实现如下。

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    UITouch *touch = [touches anyObject];
    CGPoint touchPt = [touch locationInView:self.innerImageView];
    self.currentTouchPoint = touchPt;
    NSInteger tapCount = touch.tapCount;
    switch (tapCount) {
        case 1:
            // 延时执行,防止和双击事件重叠
            [self performSelector:@selector(handleSingleTap) withObject:nil afterDelay:0.2];
            break;
        case 2:
            [self handleDoubleTap];
            break;
        default:
            break;
    }
    [[self nextResponder] touchesEnded:touches withEvent:event];
}

- (void)handleDoubleTap {
    // 取消单击事件
    [NSObject cancelPreviousPerformRequestsWithTarget:self];
    // 在适应屏幕和原始尺寸之间翻转图片的显示状态
    [self toggleStateAnimated:YES];
}

图片的批量处理

在照片的数据模型SGPhotoModel上有一个isSelected属性来判断当前图片是否被选中,通过collectionView的代理方法didUnhighlightItemAtIndexPath:来处理图片的选中与反选,为了统一点击事件,将点击缩略图进入原图浏览模式的代码也放到了这里,通过是否是编辑模式来区分,编辑模式由于和工具栏直接相关,因此被记录在工具栏中,具体实现代码如下。

- (void)collectionView:(UICollectionView *)collectionView didUnhighlightItemAtIndexPath:(nonnull NSIndexPath *)indexPath {
    SGPhotoCell *cell = (SGPhotoCell *)[collectionView cellForItemAtIndexPath:indexPath];
    // 如果处于编辑模式,则处理图片的选中和反选并返回
    if (self.toolBar.isEditing) {
        SGPhotoModel *model = self.photoAtIndexHandler(indexPath.row);
        model.isSelected = !model.isSelected;
        // 记录所有选中的图片数据模型
        if (model.isSelected) {
            [self.selectModels addObject:model];
        } else {
            [self.selectModels removeObject:model];
        }
        cell.model = model;
        return;
    }
    // 如果缩略图在下载中,则不允许进入原图浏览,hud用于指示下载进度,因此有hud则正在下载
    if (cell.imageView.hud) return;
    // 如果缩略图已经下载完毕,则允许进入原图浏览模式
    SGPhotoViewController *vc = [SGPhotoViewController new];
    vc.browser = self;
    vc.index = indexPath.row;
    [self.navigationController pushViewController:vc animated:YES];
}

更多技术细节可以在GitHub上的源码中查看,点击这里前去GitHub下载源码,欢迎Star和指出不足。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,319评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,801评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,567评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,156评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,019评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,090评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,500评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,192评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,474评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,566评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,338评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,212评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,572评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,890评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,169评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,478评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,661评论 2 335

推荐阅读更多精彩内容

  • 缘起 那时,我想要一个这样的图片浏览器: 从小图进入大图浏览时,使用转场动画 可加载网络图片,且过渡自然,不阻塞操...
    囧书阅读 6,991评论 35 107
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 11,943评论 4 60
  • 简介 虽然目前市面上有一些不错的加密相册App,但不是内置广告,就是对上传的张数有所限制。本文介绍了一个加密相册的...
    Soulghost阅读 582评论 0 7
  • 复习 copy与retain的区别: copy是创建一个新对象,retain是创建一个指针,引用对象计数加1。Co...
    叶飞楼阅读 222评论 0 0