4566 开发笔记(1)优化用户更新头像的体验

写在前面的废话

在 2015 年有一次和同事吃饭,一个同事在玩我们的 4566 App 的时候,吐槽上传用户头像太慢了,而我之前设定的交互是:上传头像的时候,界面会被 HUD 卡死,如果网速慢图片大,会卡得死死的。然后项目老大吐槽:你没压缩过图片吗。。我就裁剪了一下,不过依然是 640 * 640 分辨率的 PNG 文件,一张正常的图片大概有500K,再 Base64 一下,660多K吧,确实挺大的了。然后昨天搜了一下图片压缩相关的东西,其实压成 JPEG,0.7左右的品质都是非常清晰的,而文件大小几乎是一半,不过体验了一下微信/支付宝的上传头像功能,都是“秒传”不阻塞的,而且在网络较差的情况下也能保证头像会更新到服务器,于是我怒了,决定也写一个类似的交互。

需求分析

  • 没网络的情况下不要上传,提示用户网络中断
  • session失效的情况下,要引导用户重新登录
  • 即使网络情况较差,也要”秒传”,不用用户等待
  • 更新头像后,所有需要显示用户头像的界面都要更新
  • 如果上传图片失败了,不要提示用户重传,而是 App 自己选择在网络好的情况下自动重传
  • 如果 App 本次生命周期没有上传成功,重启 App 后,头像显示的依然是新的头像,并且在用户不知道的情况下重传
  • 如果用户重复上传头像,那么覆盖旧的,无论成功与否

“秒传”思路

用户上传图片的大致流程是:

  • 从相机或相册中获取到要上传的图片文件 Portrait Image
  • 把 Portrait Image 作为参数发起网络请求更新到服务端
  • 如果成功服务端将回调用户头像地址

我的想法是,写一个类叫 UserInfoManager

上传头像的流程如下:

  • 用户从相机或相册中获取到要上传的图片文件 Portrait Image
  • 如果当前网络断开就直接提示用户错误信息,否则下一步
  • 如果当前 session 已经失效,就引导用户重新登录,否则下一步
  • UserInfoManager 把 Portrait Image 保存到磁盘(保存的是JPEG),并另外保存一张 resize to 150 * 150 的缩略图到磁盘,保存成功后回调 succ 并展示成功的UI,HUD必须停下来,不要阻塞用户的下一步操作
  • UserInfoManager 开一个后台线程来上传缓存的 Portrait Image,如果上传成功,就以服务端回调的 portrait url 为 key 并使用 SDWebImage 来缓存当前的 Portrait Image,再把磁盘的图片文件删掉并把服务端返回的JSON同步到本地的数据库(例如UserModel),如果上传失败就判断失败的原因,如果是服务端拒绝或者奇葩错误就不要重传了,并把磁盘的文件清掉,如果是网络较差(例如AFNetworking的fail callback)就开个 NSTimer 在2分钟后重传
  • 重传的时候如果网络不可用,就再等2分钟之后再重传

UI和其它情况考虑:

  • 对于用户登录、注销、session失效(多终端互踢)的情况,需要把磁盘的图片清掉,放弃重传
  • “秒传”之后,在图片上传到服务器之前,使用的都是磁盘的本地文件,所以所有需要显示用户头像的UI都需要先判断 Disk Image 是否存在,如果存在就用 Disk Image(为了避免IO,UserInfoManager应该在 App 启动后就读磁盘图片到内存,并用一个指针指向该图片对象),不存在再用 SDWebImage 来 load portrait url
  • 如果用户在图片上传成功之前立即又上传新的头像,就应该用新的图片数据覆写现有的图片文件,并取消之前的网络请求(所以网络请求必须是可取消的),关闭之前的定时器等等,释放上一次的资源,再进入”秒传”的逻辑

App启动后要怎么处理

  • 同步读 Portrait Image 缩略图
  • 异步读 Portrait Image 原图,避免磁盘 IO 阻塞 App 启动
  • 监听用户登录、注销、session失效的消息
  • 如果发现 Portrait Image 原图存在,就启动重传图片的逻辑
  • 对于使用到用户头像的 UI,先判断 Portrait Image 原图是否存在,如果不存在(还没完全从磁盘读取到数据),再判断 Portrait Image 缩略图是否存在,如果不存在再用 SDWebImage load portrait url

因此,缩略图的作用是避免 App 启动时被阻塞,以及对于还没上传成功的用户头像,可以让用户看到“正确的”假数据。

为了避免读写图片文件数据不一致,读写图片文件还要加上递归锁。

源码

HGCUserInfoManager.h

#import <Foundation/Foundation.h>

#define mHGCUserInfoManager [HGCUserInfoManager sharedInstance]

@class HGCSessionModel;
@class HGCUserModel;

@interface HGCUserInfoManager : NSObject

+ (instancetype)sharedInstance;

/* Get */
- (HGCSessionModel *)getCurrentSession;
- (HGCUserModel *)getCurrentUser;

/* Insert or Update */
- (void)addCurrentSession:(HGCSessionModel *)session;
- (void)addCurrentUser:(HGCUserModel *)user;

/* Delete */
- (void)deleteCurrentSession;
- (void)deleteCurrentUser;

/* User Portrait */
- (NSString *)fullPortraitURLStringFrom:(NSString *)srcURLString;
- (UIImage *)srcOrCompressUploadingPortraitImage;
- (void)updateUserPortrait:(UIImage *)portraitImage handler:(HGCSuccFailCallback)handler;

@end

HGCUserInfoManager.m

#import "HGCUserInfoManager.h"
#import "HGCUserModel.h"
#import "JTTFileManager.h"
#import <NYXImagesKit/NYXImagesKit.h>
#import <SDWebImage/SDWebImageManager.h>
#import "HGCGlobalFunctions.h"

#import "HGCNetworkOperationManager.h"
#import "HGCGameStorageManager.h"
#import "HGCGameStorageManager+HGCUserSession.h"
#import "HGCLoginManager.h"


///////////////////////////////////////////////////////////////////////////////////////////


static NSString * const kHGCUserInfoFolderName = @"HGCUserInfo";

@interface HGCUserInfoManager ()

@property (nonatomic, assign) BOOL userSessionDidFail;
@property (nonatomic, strong) NSRecursiveLock *lock;

@property (nonatomic, strong) NSTimer *reuploadPortraitTimer;
@property (nonatomic, readwrite, strong) UIImage *compressUploadingPortraitImage;
@property (nonatomic, readwrite, strong) UIImage *uploadingPortraitImage;
@property (nonatomic, strong) HGCNetworkOperation *uploadingOperation;

@end

@implementation HGCUserInfoManager


///////////////////////////////////////////////////////////////////////////////////////////


#pragma mark - Singleton

+ (instancetype)sharedInstance {
    static id _sharedInstance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _sharedInstance = [[self alloc] init];
    });

    return _sharedInstance;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        [self setup];
    }
    return self;
}

- (void)setup {
    _userSessionDidFail = NO;

    _lock = [[NSRecursiveLock alloc] init];
    _lock.name = @"HGCUserInfoManagerLock";

    // 经过压缩的小图片,可以同步读取
    [_lock lock];
    NSData *compressImgData = [[NSData alloc] initWithContentsOfFile:[self _hgc_compressImageFilePath]];
    _compressUploadingPortraitImage = [UIImage imageWithData:compressImgData];
    [_lock unlock];

    // 初始的大图片,异步读取
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        [_lock lock];
        NSData *srcImgData = [[NSData alloc] initWithContentsOfFile:[self _hgc_srcImageFilePath]];
        _uploadingPortraitImage = [UIImage imageWithData:srcImgData];
        if (_uploadingPortraitImage) {
            // 重传图片
            DDLogWarn(@"Will resend user portrait");
            [self _hgc_uploadPortraitImageInBackground];
        }
        [_lock unlock];
    });

    // 用户重新登录、注销、session失效都要释放之前的资源
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleUserDidLoginNotification:) name:HGCUserDidLoginNotification object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleUserDidLogoutNotification:) name:HGCUserDidLogoutNotification object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleUserSessionDidFailNotification:) name:HGCUserSessionDidFailNotification object:nil];
}

- (void)dealloc {
    if (_reuploadPortraitTimer && [_reuploadPortraitTimer isValid]) {
        [_reuploadPortraitTimer invalidate];
    }
    _reuploadPortraitTimer = nil;
    _compressUploadingPortraitImage = nil;
    _uploadingPortraitImage = nil;
    _uploadingOperation = nil;
}


///////////////////////////////////////////////////////////////////////////////////////////


#pragma mark - Current User and Session

/* Get */

- (HGCSessionModel *)getCurrentSession {
    return [mHGCGameStorageManager getCurrentSession];
}

- (HGCUserModel *)getCurrentUser {
    return [mHGCGameStorageManager getCurrentUser];
}

/* Insert or Update */

- (void)addCurrentSession:(HGCSessionModel *)session {
    [mHGCGameStorageManager addCurrentSession:session];
}

- (void)addCurrentUser:(HGCUserModel *)user {
    [mHGCGameStorageManager addCurrentUser:user];
}

/* Delete */

- (void)deleteCurrentSession {
    [mHGCGameStorageManager deleteCurrentSession];
}

- (void)deleteCurrentUser {
    [mHGCGameStorageManager deleteCurrentUser];
}


///////////////////////////////////////////////////////////////////////////////////////////


#pragma mark - File Path

- (NSString *)_hgc_userInfoFolderPath {
    NSString *userinfoFolderPath = [[mJTTFileManager documentsPath] stringByAppendingPathComponent:kHGCUserInfoFolderName];
    [mJTTFileManager createFolderIfNotExists:userinfoFolderPath];

    return userinfoFolderPath;
}

- (NSString *)_hgc_compressImageFilePath {
    return [[self _hgc_userInfoFolderPath] stringByAppendingPathComponent:@"compr_portrait"];
}

- (NSString *)_hgc_srcImageFilePath {
    return [[self _hgc_userInfoFolderPath] stringByAppendingPathComponent:@"src_portrait"];
}

#pragma mark - Upload

- (void)_hgc_uploadPortraitImageInBackground {
    // 先取消之前的上传请求
    [self _hgc_cancelPreviousOperation];
    // 关闭定时器
    [self _hgc_invalidateReuploadTimer];

    // 如果网络连接中断,2分钟后重传
    if ([mHGCNetworkOperationManager isNetworkRechable] == NO) {
        [self _hgc_fireReuploadPortraitImageTimer];

        return;
    }

    // 后台上传
    __weak typeof(self) weakself = self;
    self.uploadingOperation = [HGCNetworkOperation new];
    _uploadingOperation.shouldHandleSessionFail = NO;
    [_uploadingOperation setCurrentUserInfoWithNickname:nil portraitImage:_uploadingPortraitImage portraitQuality:1.0 handler:^(BOOL succ, id responseObject, NSString *errorDesc) {

        __strong typeof(weakself) strongself = weakself;
        if (strongself == nil) {
            return;
        }

        if (succ == NO) {
            [strongself _hgc_uploadPortraitImageDidFail:errorDesc];
        }
        else {
            if (responseObject && [responseObject isEqual:[NSNull null]] == NO) {
                NSString *portrait = responseObject[@"userImg"];
                if (portrait && [portrait hgc_isNotEmpty]) {
                    // 上传成功
                    [strongself _hgc_uploadPortraitImageDidSucc:portrait];
                }
                else {
                    // 服务端返回非法,需要重传
                    [strongself _hgc_fireReuploadPortraitImageTimer];
                }
            }
            else {
                // 服务端返回非法,需要重传
                [strongself _hgc_fireReuploadPortraitImageTimer];
            }
        }
    }];
    [mHGCNetworkOperationManager addOperation:_uploadingOperation];
}

- (void)_hgc_fireReuploadPortraitImageTimer {
    // 先停掉之前的重传定时器
    [self _hgc_invalidateReuploadTimer];

    // 2分钟后重传,不重复
    DDLogWarn(@"Reupload Portrait Image Timer Fire");
    self.reuploadPortraitTimer = [NSTimer timerWithTimeInterval:120 target:self selector:@selector(_hgc_uploadPortraitImageInBackground) userInfo:nil repeats:NO];
    [[NSRunLoop mainRunLoop] addTimer:_reuploadPortraitTimer forMode:NSRunLoopCommonModes];
}

- (void)_hgc_uploadPortraitImageDidSucc:(NSString *)portrait {
    DDLogInfo(@"Upload Portrait Image Succ");

    // 还原网络请求
    self.uploadingOperation = nil;
    self.userSessionDidFail = NO;

    // 关闭定时器
    [self _hgc_invalidateReuploadTimer];

    // 在删除磁盘图片之前,按需手动设置缓存
    UIImage *tmpPortrait = _uploadingPortraitImage;
    NSString *fullPortraitURLString = [self fullPortraitURLStringFrom:portrait];
    [[[SDWebImageManager sharedManager] imageCache] storeImage:tmpPortrait forKey:fullPortraitURLString toDisk:YES];

    // 删除磁盘的图片
    [self _hgc_removePortraitImagesInDisk];

    // 更新当前登录的用户Model
    HGCUserModel *curUserModel = [self getCurrentUser];
    curUserModel.portraitURLString = portrait;
    [self addCurrentUser:curUserModel];
    [[NSNotificationCenter defaultCenter] postNotificationName:HGCUserProfileDidUpdateNotification object:nil];
}

- (void)_hgc_uploadPortraitImageDidFail:(NSString *)errorDesc {
    DDLogError(@"Upload Portrait Image Fail: %@", errorDesc);

    // 还原网络请求
    self.uploadingOperation = nil;

    if ([errorDesc isEqualToString:HGCLocalizedString(@"kHGCLocalize_error_network_badnetwork")]) {
        // 网络不好,需要重传
        [self _hgc_fireReuploadPortraitImageTimer];
    }
    else {
        // 其它网络错误,包括 session fail ,放弃上传图片

        if ([errorDesc isEqualToString:HGCLocalizedString(@"kHGCLocalize_error_network_sessionVerifyFail")]) {
            self.userSessionDidFail = YES;
        }

        // 关闭定时器
        [self _hgc_invalidateReuploadTimer];
        // 删除磁盘的图片
        [self _hgc_removePortraitImagesInDisk];
    }
}

#pragma mark - Clear

/**
 *  用户重新登录,注销登录,session失效,都要调用下列方法
 */

- (void)_hgc_cancelPreviousOperation {
    // 取消或还原网络请求
    if (_uploadingOperation) {
        [mHGCNetworkOperationManager cancelOperation:_uploadingOperation];
        self.uploadingOperation = nil;
    }
}

- (void)_hgc_invalidateReuploadTimer {
    // 关闭定时器
    if (_reuploadPortraitTimer && [_reuploadPortraitTimer isValid]) {
        [_reuploadPortraitTimer invalidate];
    }
    self.reuploadPortraitTimer = nil;
}

- (void)_hgc_removePortraitImagesInDisk {
    // 删除磁盘的图片
    [_lock lock];
    [mJTTFileManager removeItemAtPath:[self _hgc_srcImageFilePath]];
    [mJTTFileManager removeItemAtPath:[self _hgc_compressImageFilePath]];
    self.uploadingPortraitImage = nil;
    self.compressUploadingPortraitImage = nil;
    [_lock unlock];
}


///////////////////////////////////////////////////////////////////////////////////////////


#pragma mark - Public Methods

- (NSString *)fullPortraitURLStringFrom:(NSString *)portraitURLString {
    NSRange range = [portraitURLString rangeOfString:@"!" options:NSBackwardsSearch];
    NSString *fullPortraitURLString = portraitURLString;
    if (range.location != NSNotFound) {
        fullPortraitURLString = [portraitURLString substringToIndex:range.location];
    }

    return fullPortraitURLString;
}

- (UIImage *)srcOrCompressUploadingPortraitImage {
    return _uploadingPortraitImage ? _uploadingPortraitImage : (_compressUploadingPortraitImage ? _compressUploadingPortraitImage : nil);
}

/**
 *  提供给上传用户头像的 Controller 调用的接口
 */
- (void)updateUserPortrait:(UIImage *)portraitImage handler:(HGCSuccFailCallback)handler {
    if ([mHGCNetworkOperationManager isNetworkRechable] == NO) {
        // 网络中断,直接提示用户
        !handler ?: handler(NO, nil, HGCLocalizedString(@"kHGCLocalize_error_network_disconnected"));
    }
    else if (_userSessionDidFail) {
        // session 失效,提示用户重新登录
        [mHGCLoginManager handleSessionDisconnected];
        !handler ?: handler(NO, nil, HGCLocalizedString(@"kHGCLocalize_error_network_sessionVerifyFail"));
    }
    else {
        /** "秒传" */

        // 先保存原图和缩略图到磁盘
        [_lock lock];
        self.uploadingPortraitImage = portraitImage;
        NSData *srcImageData = HGCFDataFromImage(_uploadingPortraitImage);
        BOOL writeSrcImgSucc = [srcImageData writeToFile:[self _hgc_srcImageFilePath] atomically:YES];
        if (writeSrcImgSucc == NO) {
            DDLogWarn(@"Save src portrait fail");
        }
        self.compressUploadingPortraitImage = [portraitImage scaleToFillSize:CGSizeMake(150, 150)];
        NSData *comprImageData = HGCFDataFromImage(_compressUploadingPortraitImage);
        BOOL writeComprImgSucc = [comprImageData writeToFile:[self _hgc_compressImageFilePath] atomically:YES];
        if (writeComprImgSucc == NO) {
            DDLogWarn(@"Save compr portrait fail");
        }
        [_lock unlock];

        // 在后台开线程上传原图
        [self _hgc_uploadPortraitImageInBackground];

        // 回调成功给用户
        !handler ?: handler(YES, nil, nil);
    }
}


///////////////////////////////////////////////////////////////////////////////////////////


#pragma mark - Notifications Handlers

- (void)handleUserDidLoginNotification:(NSNotification *)noti {
    [self _hgc_cancelPreviousOperation];
    [self _hgc_invalidateReuploadTimer];
    [self _hgc_removePortraitImagesInDisk];

    self.userSessionDidFail = NO;
}

- (void)handleUserDidLogoutNotification:(NSNotification *)noti {
    [self _hgc_cancelPreviousOperation];
    [self _hgc_invalidateReuploadTimer];
    [self _hgc_removePortraitImagesInDisk];

    self.userSessionDidFail = NO;
}

- (void)handleUserSessionDidFailNotification:(NSNotification *)noti {
    [self _hgc_cancelPreviousOperation];
    [self _hgc_invalidateReuploadTimer];
    [self _hgc_removePortraitImagesInDisk];

    self.userSessionDidFail = YES;
}

@end

还需要在 AppDelegate.m 的 App Launch 方法中调用

mHGCUserInfoManager;

小结

一个看似简单的功能,如果要保证各种情况下都有效,尤其是网络不好等情况,就会变得非常的复杂。

!DOCTYPE html> <html 85.3333px;"> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0, minimum-scale=1.0, maximum-scale=1.0"> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta http-equiv="Cache-Control" content="no-transform"> <meta name="applicable-device" content="mobile"> <meta http-equiv="Cache-Control" content="no-siteapp"> <title>【元旦倒计时】2020新年倒计时_距离2020年还有多少天</title> <style> .timebox2{width:640px;background-color:#FCF3E2;padding: 1rem 0;background: url(images/ks_bcg2.png) center center no-repeat;background-size: 100% 100%;} .timebox2 div,timebox2 span,timebox2 div span{color: #333 !important;} .timebox2 .timecounter span{color: #333 !important;} .timetext{width: 100%;text-align: center;} .timetexttitle{font-size:0.3rem;margin-bottom: 10px;color: #fff;} .timetextintro{font-size: 0.22rem;margin-bottom: 10px;color: #fff;padding: 0 1.52rem;box-sizing: border-box;} .timecounter{font-size: 0.6rem;} .txt{padding: 0 0.2rem;box-sizing:border-box;} .clearbg5{display: block;clear: both;width: 100%;height: 0.16rem;overflow: hidden;background-color: #eee;} .txt p{line-height: 30px;margin-top: 0.2rem;} .txt p img{width: 100%;} .timecounter span{color: #fff;} </style> <body> 2020年元旦倒计时 元旦时间:2020年1月1日 农历腊月初七 星期三 目前距离2020年元旦还有 127 天 10 时 24 分 8 秒 [removed] function getRTime() { var EndTime = new Date("2020/01/01 00:00:00"); //截止时间 var NowTime = new Date(); var t = EndTime.getTime() - NowTime.getTime(); var d = Math.floor(t / 1000 / 60 / 60 / 24); var h = Math.floor(t / 1000 / 60 / 60 % 24); var m = Math.floor(t / 1000 / 60 % 60); var s = Math.floor(t / 1000 % 60); document.getElementById("t_d")[removed] = d + " 天"; document.getElementById("t_h")[removed] = h + " 时"; document.getElementById("t_m")[removed] = m + " 分"; document.getElementById("t_s")[removed] = s + " 秒"; } setInterval(getRTime, 1000); [removed] </body> </html>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值