iOS开源加密相册Agony的实现(七)
简介
虽然目前市面上有一些不错的加密相册App,但不是内置广告,就是对上传的张数有所限制。本文介绍了一个加密相册的制作过程,该加密相册将包括多密码(输入不同的密码即可访问不同的空间,可掩人耳目)、WiFi传图、照片文件加密等功能。目前项目和文章会同时前进,项目的源代码可以在github上下载。
点击前往GitHub
概述
上一篇文章主要介绍了图片浏览器原图浏览、缩放和滑动切换图片的实现细节。本文主要介绍原图浏览实现的技术细节,其中包括了对内存占用的优化。
回顾
上节介绍了用于处理图片缩放的SGZoomingImageView,其实质是ScrollView+ImageView,scrollView的contentSize随着imageView的尺寸而变化,并且scrollView自带了对捏合手势缩放图片的支持。
图片切换是UIScrollView+SGZoomingScrollView,对scrollView进行分页,每一页都是一个SGZoomingScrollView,显示一张图片。
原图浏览控制器SGPhotoViewController
调用顺序
当点击了一张缩略图,就会push出原图浏览控制器SGPhotoViewController
从而进入原图浏览状态,代码如下。
该方法属于缩略图浏览控制器SGPhotoBrowser,具体讲解可以在第四篇文章中找到
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
// 如果处于编辑状态(批处理照片),则不进入原图浏览,而是通过didUnhighlightItemAtIndexPath:方法处理图片的选中与反选
if (self.toolBar.isEditing) {
return;
}
SGPhotoViewController *vc = [SGPhotoViewController new];
vc.browser = self;
vc.index = indexPath.row;
[self.navigationController pushViewController:vc animated:YES];
}
SGPhotoViewController中包含了一个SGPhotoView,SGPhotoView继承了UIScrollView,用于放置多个SGZoomingImageView来处理图片切换。它获取数据的方式仍然是通过browser的数据源(block回调),因此上面的代码中将browser传递了进来,同时需要当前图片的索引,以确定要查看哪一张图片。
SGPhotoViewController负责处理SGPhotoView的添加,单击事件(隐藏和显示导航栏、工具栏)和工具栏的动作(删除、导出照片),具体代码如下。
* 声明代码 *
@interface SGPhotoViewController : UIViewController
// 用于通过数据源block获取数据
@property (nonatomic, weak) SGPhotoBrowser *browser;
// 当前照片的模型索引
@property (nonatomic, assign) NSInteger index;
@end
// 拓展
@interface SGPhotoViewController ()
// 用于记录是否隐藏了导航栏与工具栏
@property (nonatomic, assign) BOOL isBarHidden;
// 用于显示原图的视图,继承自UIScrollView
@property (nonatomic, weak) SGPhotoView *photoView;
// 底部工具条,能够提供删除、导出操作
@property (nonatomic, weak) SGPhotoToolBar *toolBar;
@end
* 实现代码 *
- (void)viewDidLoad {
[super viewDidLoad];
// 添加SGPhotoView,通过addSubView:而不是loadView的原因在下面讲解
[self setupView];
// 防止scrollView的原点跟随导航栏自己变动
self.automaticallyAdjustsScrollViewInsets = NO;
// 为了防止循环引用,定义weakSelf的宏
WS();
// 控制器处理photoView的单击事件,用于翻转导航栏和工具栏的显示隐藏状态
[self.photoView setSingleTapHandlerBlock:^{
[weakSelf toggleBarState];
}];
}
/*
通过addSubView:而不是loadView加载SGPhotoView
是因为SGPhotoView需要向左偏移-d的距离,来保持每一张图片之间的间隔(上一篇文章有讲),如果通过loadView将控制器视图指定为SGPhotoView,则
需要到viewWillAppear:才能调整view的坐标。
*/
- (void)setupView {
SGPhotoView *photoView = [SGPhotoView new];
self.photoView = photoView;
// photoView需要弱引用控制器,以便更改导航栏标题(显示当前是第几张)
self.photoView.controller = self;
// photoView需要browser以通过数据源获取数据
self.photoView.browser = self.browser;
// photoView需要知道当前是第几张照片
self.photoView.index = self.index;
[self.view addSubview:photoView];
// photoView的左侧有一个宽为d的黑边,因此需要将photoView向左偏移d
CGFloat x = -PhotoGutt;
CGFloat y = 0;
CGFloat w = self.view.bounds.size.width + 2 * PhotoGutt;
CGFloat h = self.view.bounds.size.height;
self.photoView.frame = CGRectMake(x, y, w, h);
CGFloat barW = self.view.bounds.size.width;
CGFloat barH = 44;
CGFloat barX = 0;
CGFloat barY = self.view.bounds.size.height - barH;
// 底部工具条,用于进行导出和删除操作,工具条和第五篇提到的一样,继承SGBlockToolBar,通过block回调
SGPhotoToolBar *tooBar = [[SGPhotoToolBar alloc] initWithFrame:CGRectMake(barX, barY, barW, barH)];
self.toolBar = tooBar;
[self.view addSubview:tooBar];
WS();
// 处理工具栏的动作,tag在SGPhotoToolBar中定义
[self.toolBar setButtonActionHandlerBlock:^(UIBarButtonItem *sender) {
switch (sender.tag) {
case SGPhotoToolBarTrashTag:
[weakSelf trashAction];
break;
case SGPhotoToolBarExportTag:
[weakSelf exportAction];
break;
default:
break;
}
}];
}
到这里为止,就完成了photoView和工具栏的加载,接下来就是一些细节了。
导航栏与工具栏的显示与隐藏
导航栏在隐藏时,应该同时把状态栏隐藏,而状态栏的隐藏在iOS7以后是默认通过控制器管理的,通过控制器的prefersStatusBarHidden方法返回是否显示,如果要更新状态栏状态,则使用setNeedsStatusBarAppearanceUpdate方法,如下。
- (void)toggleBarState {
self.isBarHidden = !self.isBarHidden;
[self setNeedsStatusBarAppearanceUpdate];
}
- (BOOL)prefersStatusBarHidden {
return self.isBarHidden;
}
当然也可以通过UIApplication单例来操作,但在iOS7之后其优先级比上面的方式要低,实现如下。
- (void)toggleBarState {
self.isBarHidden = !self.isBarHidden;
[[UIApplication sharedApplication] setStatusBarHidden:self.isBarHidden withAnimation:YES];
}
如果想只是用下面的方法来调整工具栏,则需要在info.plist中设置View controller-based status bar appearance
为NO。
除此之外还需要处理对导航栏和工具栏的隐藏,其完整实现如下。
- (void)toggleBarState {
self.isBarHidden = !self.isBarHidden;
[[UIApplication sharedApplication] setStatusBarHidden:self.isBarHidden withAnimation:NO];
[self.navigationController setNavigationBarHidden:self.isBarHidden animated:YES];
[UIView animateWithDuration:0.35 animations:^{
self.toolBar.alpha = self.isBarHidden ? 0 : 1.0f;
}];
}
删除图片动作
工具条的删除动作通过block回调到原图控制器,并且执行trashAction方法,该方法先通过ActionSheet来让用户确认是否真的要删除,如果确认,则根据当前照片模型数据删除文件,并且通过browser的数据源去请求重新加载数据。
- (void)trashAction {
// block回调的ActionSheet
[[[SGBlockActionSheet alloc] initWithTitle:@"Please Confirm Delete" callback:^(UIActionSheet *actionSheet, NSInteger buttonIndex) {
if (buttonIndex == 0) {
// photoView的currentPhoto为展示中的图片,下文讲解细节
[[NSFileManager defaultManager] removeItemAtPath:self.photoView.currentPhoto.photoURL.path error:nil];
[[NSFileManager defaultManager] removeItemAtPath:self.photoView.currentPhoto.thumbURL.path error:nil];
[self.navigationController popViewControllerAnimated:YES];
// browser的子类必须实现reload数据源block来通知其重新从文件系统中加载数据,以便显示删除后的效果
NSAssert(self.browser.reloadHandler != nil, @"you must implement 'reloadHandler' block to reload files while delete");
self.browser.reloadHandler();
// 重新加载文件只是加载了模型数据,还需要collectionView重新加载数据
[self.browser reloadData];
}
} cancelButtonTitle:@"Cancel" destructiveButtonTitle:@"Delete" otherButtonTitlesArray:nil] showInView:self.view];
}
导出图片动作
使用ALAssetsLibrary的writeImageToSavedPhotosAlbum:::方法即可向系统相册写入数据,注意该方法为异步,具体实现如下。
- (void)exportAction {
[[[SGBlockActionSheet alloc] initWithTitle:@"Save To Where" callback:^(UIActionSheet *actionSheet, NSInteger buttonIndex) {
if (buttonIndex == 1) {
ALAssetsLibrary *lib = [ALAssetsLibrary new];
// currentImageView是photoView正在显示的照片的SGZoomingImageView对象,下文详细讲解
UIImage *image = self.photoView.currentImageView.innerImageView.image;
[MBProgressHUD showMessage:@"Saving"];
[lib writeImageToSavedPhotosAlbum:image.CGImage metadata:nil completionBlock:^(NSURL *assetURL, NSError *error) {
[MBProgressHUD hideHUD];
[MBProgressHUD showSuccess:@"Succeeded"];
}];
}
} cancelButtonTitle:@"Cancel" destructiveButtonTitle:nil otherButtonTitlesArray:@[@"Photo Library"]] showInView:self.view];
}
原图浏览视图SGPhotoView
类结构
用于原图浏览的核心即为SGPhotoView,它是一个scrollView,上面分页排布着SGZoomingImageView对象,其结构如下。
// 照片的间距
#define PhotoGutt 20
// 单击事件的回调block定义,用于交给控制器处理
typedef void(^SGPhotoViewTapHandlerBlcok)(void);
@interface SGPhotoView : UIScrollView
@property (nonatomic, weak) SGPhotoViewController *controller;
@property (nonatomic, weak) SGPhotoBrowser *browser;
// 当前浏览的图片索引,每次从缩略图进入原图浏览时需要传入以初始化,在左右滑动时自动更新
@property (nonatomic, assign) NSInteger index;
// 用于控制器获取当前模型与当前SGZoomingImageView对象
@property (nonatomic, strong) SGPhotoModel *currentPhoto;
@property (nonatomic, weak) SGZoomingImageView *currentImageView;
// 单击事件的block回调setter
- (void)setSingleTapHandlerBlock:(SGPhotoViewTapHandlerBlcok)handler;
@end
// 拓展
@interface SGPhotoView () <UIScrollViewDelegate> {
// 每页的宽度,由于左右切图时根据偏移量计算当前图片索引
CGFloat _pageW;
}
@property (nonatomic, copy) SGPhotoViewTapHandlerBlcok singleTapHandler;
// 用于存储显示每一张图片的对象
@property (nonatomic, strong) NSArray<SGZoomingImageView *> *imageViews;
@end
初始化
首先是类初始化时的参数初始化,包括背景色、分页、UIScrollView的代理。
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
[self commonInit];
}
return self;
}
- (void)commonInit {
self.backgroundColor = [UIColor blackColor];
self.pagingEnabled = YES;
self.delegate = self;
}
接下来是通过browser和index的setter来初始化每一张图片。
* 通过Browser的setter来初始化每一张图片对象 *
由于涉及到在分页的scrollView上实现间距,计算较为复杂,关于间距计算的讲解可以参考第六篇文章。
// 简言之就是将每张图片对象放到photoView的正确位置,并将这些图片对象引用
- (void)setBrowser:(SGPhotoBrowser *)browser {
_browser = browser;
NSInteger count = browser.numberOfPhotosHandler();
CGSize visibleSize = [UIScreen mainScreen].bounds.size;
NSMutableArray *imageViews = @[].mutableCopy;
CGFloat imageViewWidth = visibleSize.width + PhotoGutt * 2;
_pageW = imageViewWidth;
self.contentSize = CGSizeMake(count * imageViewWidth, 0);
for (NSUInteger i = 0; i < count; i++) {
SGZoomingImageView *imageView = [SGZoomingImageView new];
SGPhotoModel *model = self.browser.photoAtIndexHandler(i);
[imageView.innerImageView sg_setImageWithURL:model.thumbURL];
imageView.isOrigin = NO;
CGRect frame = (CGRect){imageViewWidth * i, 0, imageViewWidth, visibleSize.height};
imageView.frame = CGRectInset(frame, PhotoGutt, 0);
[imageViews addObject:imageView];
[self addSubview:imageView];
[imageView scaleToFitAnimated:NO];
}
self.imageViews = imageViews;
}
内存优化
* 通过index的setter来使得photoView滚动到特定位置并加载高清图 *
为了优化内存,除去正在展示的图片和与其相邻的图片,加载的都是缩略图,在切换过程中会动态的计算应该显示原图的位置,并将不相邻的原图全部置为缩略图,具体实现如下。
- (void)setIndex:(NSInteger)index {
_index = index;
CGSize visibleSize = [UIScreen mainScreen].bounds.size;
// 根据index翻到特定的页
self.contentOffset = CGPointMake(index * _pageW, 0);
[self loadImageAtIndex:index];
}
- (void)loadImageAtIndex:(NSInteger)index {
// 更新控制器标题为当前图片索引,例如一共九张,当前是第三张,则是"3 Of 9"
[self updateNavBarTitleWithIndex:index];
// 通过browser的数据源获取模型总数
NSInteger count = self.browser.numberOfPhotosHandler();
// 遍历所有的照片对象
for (NSInteger i = 0; i < count; i++) {
通过browser的数据源获取模型数据
SGPhotoModel *model = self.browser.photoAtIndexHandler(i);
SGZoomingImageView *imageView = self.imageViews[i];
// 对于当前显示的图片进行引用,其他图片都缩放到适应屏幕(图片缩放在第六篇文章有讲解)
if (i == index) {
self.currentImageView = imageView;
} else {
[imageView scaleToFitIfNeededAnimated:NO];
}
NSURL *photoURL = model.photoURL;
NSURL *thumbURL = model.thumbURL;
// 对于当前图片以及相邻图片,如果没有加载原图,则去加载原图替换缩略图,并且变换到适应屏幕大小
if (i >= index - 1 && i <= index + 1) {
if (imageView.isOrigin) continue;
[imageView.innerImageView sg_setImageWithURL:photoURL];
imageView.isOrigin = YES;
[imageView scaleToFitAnimated:NO];
} else {
// 对于其他图片,如果仍然持有原图,则用缩略图替换之,以节约内存
if (!imageView.isOrigin) continue;
[imageView.innerImageView sg_setImageWithURL:thumbURL];
imageView.isOrigin = NO;
[imageView scaleToFitAnimated:NO];
}
}
}
其他细节
* 设置当前图片单击手势的回调 *
在第六篇文章中讲到,单击和双击由照片对象SGZoomingImageView捕获,双击在类内处理,而单击传递到类外的photoView,再传递到控制器以翻转bar的显示状态,因此应该在设置当前图片对象时先清除已经不在屏幕上显示的图片对象的block回调,并且将当前显示的图片对象的block进行设置,这些可以在currentImageView的setter中实现,具体如下。
- (void)setCurrentImageView:(SGZoomingImageView *)currentImageView {
// 如果赋值前不为空说明之前有其他图片对象被展示,先清空其回调再赋值
if (_currentImageView != nil) {
[_currentImageView setSingleTapHandler:nil];
}
_currentImageView = currentImageView;
WS(); // 定义weakSelf,防止循环引用
[_currentImageView setSingleTapHandler:^{
// 通过block继续向控制器传递单击事件
if (weakSelf.singleTapHandler) {
weakSelf.singleTapHandler();
}
}];
}
* 在滑动切换图片时更新index并处理原图的装载与卸载 *
为了防止左右滚动时卡顿,在滚动结束后才进行处理,通过UIScrollView的scrollViewDidEndDecelerating:代理回调,该方法在scrollView减速完毕后调用。
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
CGFloat offsetX = scrollView.contentOffset.x;
// 根据当前偏移和页宽计算索引,与scrollView的页面切换规则一致,偏移超过50%的页宽就切换到下一页
NSInteger index = (offsetX + _pageW * 0.5f) / _pageW;
if (_index != index) {
_index = index;
[self loadImageAtIndex:_index];
}
}
总结
本文主要讲了完成原图浏览与图片切换的细节,并对内存占用进行了优化,到这里为止,加密相册的Demo就基本介绍完毕了,由于代码较多,文中只能挑重点讲解,项目的源代码在文首的地址中可以找到,欢迎关注项目后续。