iOS程序員眼中的客戶端免登錄(數據遷移已更新)

2017.01.15前端

1、前言,爲何要作免登錄

  • 2017年1月9日,蓄勢已久的小程序正式上線,着實,張小龍 用完即走 的理念發揮的淋漓盡致,無需下載,掃碼可用,用完即走
  • 2017年互聯網人口紅利結束了,那麼接下來除了__內容的精耕細做__外,就是__提升流量的轉化率__,然而在流量轉化爲真實用戶的道路上,一個登錄註冊的入口擋住了運營活動多少真金白銀砸出來的流量?
  • 在談免登錄以前呢,我想先大概說下客戶端登陸,想必你們都耳熟能詳,通常狀況下須要包含如下幾個方面【括號內部分爲可選項】:
  • SNS 第三方快捷登錄
  • 郵箱+(驗證碼)+密碼 登陸註冊
  • 手機號+驗證碼+(密碼)登陸註冊
  • (提示用戶上次在本機登陸方式 && 帳號)

  • 毫無疑問,相比於手機號、郵箱的登陸註冊,第三方登陸是最方便的,在第三方app已經登陸的前提下,須要如下兩步操做:
    1. 用戶第一次打開app須要小手點一下第三方圖標
    2. 跳轉到對應app後,點一下 確認受權 按鈕便可返回本身的app完成登陸

可是!!!

  • 在用戶還沒體驗到你app任何亮點以前,憑什麼讓用戶進行如此繁雜的操做,不要讓用戶思考!不要讓用戶麻煩!尤爲是用戶對隱私日漸重視的今天!!且不說某麥某東等用戶帳號密碼泄露,就說前幾天某德利用手中大數據強行一把秀優越。。。

我就問你要是凱迪拉克車主你還會用高德麼?!(默默掏出褲兜裏的地鐵卡看了一眼。。)web

  • 結論是用戶是愈來愈重視本身的隱私的,用戶在使用 app 的時候也不想進行任何多餘的思考
  • 所以在用戶下載 app 以後第一次打開,要狠下心去掉一切沒必要要的彈框(除國行iOS10必須彈出的蜂窩網絡權限以外,其餘接收通知、定位等權限最好放在須要的時候再彈出)
  • 除特殊軟件(如網絡電話)必須使用電話號碼註冊的,其餘相似電商、內容瀏覽、交友軟件、工具類等 app,都應該進行免登錄操做先讓用戶體驗 app 的基本功能,在一些深度使用的高級功能上個添加門檻,提示用戶進行登陸註冊操做

2、來幾個經常使用 app 的例子

1. 今日頭條:
  • 打開 app 後以遊客身份進入,能夠進行常規的新聞瀏覽、查看評論、收藏、分享、消息反饋等操做
  • 進行爆料、評論、查看閱讀歷史等操做的時候彈出登陸框

點擊更多登陸方式效果

  • 登陸成功後,以前收藏的數據已遷移到正式用戶名下
  • 若是實在發送評論的時候觸發的登陸操做,登陸成功後評論發出,提示用戶評論發送成功
2. 每日開眼
  • 一樣的,進入 app 後可正常瀏覽,視頻狀態下進行點贊操做觸發登陸,你看這位女施主懸浮在泳池中,享受着柔和的陽光和微微清風,那曼妙的身材真是讓做爲用戶的我忍不住登陸,再退出,再登陸。。。

可是!!!

若是你以爲我是由於女主人公的照片才舉這個例子,呵呵,在下可不是那麼膚淺的人,開眼的內容和設計以及 app 總體流暢度都很棒,可是免登錄這裏有兩個小瑕疵,在遊客+橫屏狀態下

  • 觀看視頻的時候,點擊收藏按鈕,直接modal出豎屏的登陸框,這點對用戶不是很友好
  • 登陸成功後,沒有自動延續用戶在登陸以前的操做(收藏)

關於這兩點的技術實現後面會講sql

3、總體流程

  1. 用戶首次進入 app 以後,判斷以前是否在本機登陸過,若是是用戶首次登陸,就調用 遊客登陸API,固然這個遊客 guestId 是服務器根據設備號生成的,通常狀況下,一個設備對應一個遊客 guestId,並且這個遊客 guestId 固然是不能展現給用戶的(也能夠在該接口返回一個上次登陸信息,提示用戶上次登陸方式)

iPhone設備各類信息獲取傳送門數據庫

  • 而後使用這個遊客 guestId 進行各項參數的初始化,好比數據庫存取地址、下載文件路徑、瀏覽記錄等各方面操做的統計,固然該遊客在進行通常操做的時候,就是使用這個遊客 guestId 與服務器進行交互
  • 接着就要考慮彈出登陸框的具體時機,固然每一個 app 的產品特性不同,通常會在如下幾種狀況下彈出登陸框:收藏、評論、購買會員、下單購買商品等深度操做。
  • 還有就是萬萬不能在如下幾種狀況下彈出登陸框:分享、用戶反饋、添加到購物車等,由於這些操做是用戶主動幫助分享app,提出意見,這時候彈出登陸框,簡直是搞事情!
  • 彈出登陸框(注意橫豎屏的適配),用戶選擇進行登陸後,獲取到一個正式的用戶 userId,從新初始化各項參數,隱藏登陸頁,進行數據庫遷移合併、下載內容路徑遷移(大多下載須要用戶相應的權限,防止做弊)、歷史記錄遷移合併、購物車內容遷移合併等
  • 最後繼續進行用戶須要登陸以前的操做(經過block來實現)
  • 若用戶進行退出登陸操做,先調用退出登陸的api,而後再調用遊客登陸的api

4、上代碼以前,談談登陸註冊的一些小細節

  • 進入到登陸註冊頁後,鍵盤應馬上彈出,須要郵箱的彈出字母鍵盤,須要手機號的彈出數字鍵盤
  • 當 兩個輸入框內容沒有都達標以前,action按鈕應該設置爲disabled
  • 輸入內容的時候考慮小屏幕適配,自動滑動到合適位置
  • 在文本輸入框有內容以後,右側應該設置❎按鈕,供用戶一鍵刪除
  • 帳號有沒有長度限制,相似電話格式的判斷在前端作比較方便,好比在密碼框 becomeFirstResponder 的時候,就直接判斷帳號格式,若是錯誤需提示用戶
  • 密碼輸入框須要設置明文暗文按鈕,以供用戶隨時校驗
  • 點擊登陸按鈕後彈出菊花(固然我指的是 UIActivityIndicatorView,不是那個肥皂那個菊花)或者動畫,防止屢次發送網絡請求
  • 對於登陸註冊信息出錯,這個最好是能作到及時反饋,考慮下web端註冊帳戶的時候,暱稱是否已被佔用可以在用戶輸入就提示,若是每次興沖沖輸入一大堆消息後,滿懷期待的點擊註冊按鈕,結果提示「您的暱稱已被佔用」,你對這個網站的好感是否是會下降那麼一丟丟?所以最好可以在保證用戶行爲流暢的基礎上提示用戶,好比
    • 暱稱限制10位,那麼輸入第11位的時候就應該是無效的
    • 最好統一登陸註冊界面:用戶輸入手機號、郵箱以後,實時查詢數據庫是否已註冊,而後更新按鈕狀態
  • 也須要考慮網絡超時、請求出錯、服務器宕機、短信未發送成功等異常信息
  • 對於一些金融類相關的app,爲了防止服務器被攻擊(固然也),是否是要考慮同一IP請求兩次後添加驗證碼(倒計時通常是前端固定的代碼)
  • 若是登陸失敗,提示的信息必定要準確,好比是驗證碼錯誤,仍是帳戶名密碼錯誤雖然這個提示信息通常都是服務器同窗來作

5、代碼設計:啥都別說了,都在代碼裏

1. 首先在全局的控制器管理類寫一個彈出 view 的方法
/** 大多狀況下默認的添加方式,直接添加到最頂層的控制器上 * title:彈出登陸框的提示語,如登陸後方可進行評論 * block:用戶被登陸框所阻攔的操做(注意循環引用) */
- (void)transferControlToPortalViewWithTitle:(NSString *)title block:(void(^)())block;
複製代碼
2. 而後在收藏等深度操做須要提示遊客登陸的點擊事件裏面判斷
- (void)favoredBtnTapped:(UIButton *)sender {
    // 若是是遊客帳戶,就提示用戶進行登陸操做,不然就進行正常的收藏按鈕點擊事件
    if ([self.systemAccountManager isGuest]) {
       [self.systemVCManager transferControlToPortalViewWithTitle:@"登陸後可進行收藏操做" block:^{
           [weakSelf doFavoredAction];
       }];
    } else {
       [self doFavoredAction];
    }
}
複製代碼
3. 好比要實現上文中提到的 今日頭條 樣式的登陸框,不能用 present 也不能用 modal,由於那樣的話上一級的控制器視圖就會被移到另一個 Window 上,不能實現其在原界面添加半透明遮罩的效果,所以採用下列方式
[fatherVC addChildViewController:portalVC];
[fatherVC.view addSubview:portalVC.view];
複製代碼

此處更正一下,感謝 CZAnchor 提出的方法,這裏是能夠經過 present 方式實現的,代碼以下:小程序

UIViewController *rootVC = [UIApplication sharedApplication].keyWindow.rootViewController;
UIViewController *baseVC = rootVC;

while (baseVC.presentedViewController) {
   baseVC = baseVC.presentedViewController;
}

if ([[UIDevice currentDevice].systemVersion floatValue] >= 8.0) {
    portalVC.modalPresentationStyle = UIModalPresentationOverCurrentContext;
    baseVC.definesPresentationContext = YES;
    [baseVC presentViewController:portalVC animated:NO completion:^{}];
} else {
    baseVC.modalPresentationStyle = UIModalPresentationCurrentContext;
    [baseVC presentViewController:portalVC animated:NO completion:^{}];
}
複製代碼
4. 在調用登陸接口的成功回調裏面,須要進行兩個操做
4.1 首先進行數據遷移:
  • 已下載內容文件 的遷移,因爲某些下載內容是須要相應權限的,所以都是每一個帳號對應一個存儲路徑,也是在必定程度上防止帳號過度共享形成的利益損失
/** 遷移已下載的文件 */
#warning 關於遊客狀態下下載的內容,須要考慮兩部分:
1. 登陸的正式用戶以前未在本機上登陸過,建立用戶的下載路徑後直接將遊客的下載內容所有遷移過去(若只是登陸過沒有下載內容,就直接所有遷移過去);
2. 登陸的正式有用戶以前在本機登陸過並有下載內容,則須要將兩個路徑下的下載內容合併
- (void)transferDownLoadedFile {
    // 獲取下載文件根路徑
    NSArray *paths = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES);
    NSString *libraryDir = [paths objectAtIndex:0];
    NSString *rootFilePath = [NSString stringWithFormat:@"%@/%@",libraryDir,@"## 這裏是項目中下載文件的路徑 ##"];
    
    // 分別獲取遊客和正式用戶的下載路徑(方便起見直接使用對應ID做爲路徑名稱)
    NSString *guestPath = [NSString stringWithFormat:@"%@/%@", rootFilePath, self.accountManager.guestId];
    NSString *userPath = [NSString stringWithFormat:@"%@/%@", rootFilePath, self.accountManager.userId];

    // 獲取文件管理器
    NSFileManager *manager = [NSFileManager defaultManager];

    // 獲取遊客的下載文件數組 
    NSError *error = nil;
    NSArray *guestFilesArr = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:guestPath error:&error];
    if (error) {
        NSLog(@"contentsOfDirectoryAtPath guestPath:%@", error);
    }
    
    // 遍歷遊客的文件
    for (NSString *fileName in guestFilesArr) {
        //  拼接處 該文件在 遊客狀態 && 正式用戶狀態 的存儲路徑
        NSString *guestFileDir = [guestPath stringByAppendingPathComponent:fileName];
        NSString *userFileDir = [userPath stringByAppendingPathComponent:fileName];
        // 若是正式用戶 下載文件中不包含該文件,就建立一下
        if (![manager fileExistsAtPath:userFileDir]) {
            [manager createDirectoryAtPath:userFileDir withIntermediateDirectories:YES attributes:nil error:&error];
        }

        BOOL isDir;
        if ([manager fileExistsAtPath:guestFileDir isDirectory:&isDir] && isDir) {
            error = nil;
            NSArray *childFiles = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:guestFileDir error:&error];
            if (error) {
                NSLog(@"contentsOfDirectoryAtPath dir:%@", error);
            }
            // 遍歷該文件夾內子文件,所有遷移到 正式用戶 名下的文件
            for (NSString *childFile in childFiles) {
                NSString *filePath = [guestFileDir stringByAppendingPathComponent:childFile];
                NSString *destPath = [userFileDir stringByAppendingPathComponent:childFile];
                error = nil;
                [manager moveItemAtPath:filePath toPath:userFileDir error:&error];
                if (error) {
                    DDLogError(@"moveItemAtPath to path error:%@", error);
                    //若是正式用戶下該文件存在(即用戶以前在本機登陸並下載過該文件)會報錯,那麼就將遊客路徑下的改文件刪除
                    [manager removeItemAtPath:filePath error:&error];
                }
            }
        }
    }
}
複製代碼
  • 遷移數據庫:這部份內容着實跟項目本分的業務、封裝關係太大,在這裏以一個 video 文件的下載記錄爲例,以 FMDB 爲載體大概講一下思路
// 1. 獲取遊客的 db 文件路徑 guestDataBasePath
// 2. 打開遊客該 db 文件
fmDataQueue = [FMDatabaseQueue databaseQueueWithPath:path];
 [fmDataQueue inDatabase:^(FMDatabase *fmDatabase) {
     if ([fmDatabase open]) {
        [fmDatabase setShouldCacheStatements:YES];
        //  建立 SQL 語句
        NSString *sqlStr = [NSString stringWithFormat:@"%@%@%@%@%@%@%@",
                    @"CREATE TABLE IF NOT EXISTS MYVIDEO  (VIDEOID TEXT  PRIMARY KEY ",
                    @",videoname      TEXT",
                    @",info           TEXT",
                    @",coverfilename  TEXT",
                    @",urlpath        TEXT")"];
         BOOL isExecute = [fmDatabase executeUpdate:createStatement];
         if (isExecute) {
             // 若有必要,可檢查一下表結構是否已升級,此處再也不贅述
         } else {
             NSLog(@"error occured while creating MYVIDEO table");
         }
     } else {
     NSLog(@"open datebase failed");
     }
}

// 3. 查詢遊客帳戶下已下載的 video
//  建立空數組用於存放 video 對象
NSMutableArray *videoArray = [[NSMutableArray alloc] init];
[fmDataQueue inDatabase:^(FMDatabase *fmDatabase) {
    // 書寫 sql 語句
    NSString *query = [NSString stringWithFormat:@"SELECT videoid,videoname,info,coverfilename,urlpath, FROM MYVIDEO "];
    NSString *sqlQuery;
    if (wheresql != nil) {
        sqlQuery = [NSString stringWithFormat:@"%@%@", query, wheresql];
    } else {
        sqlQuery = query;
    }
    // 按時間降序排序
    sqlQuery = [sqlQuery stringByAppendingString:@" ORDER BY time DESC "];
    FMResultSet *resultSet = [fmDatabase executeQuery:sqlQuery];
    if ([fmDatabase hadError]) {
        NSLog(@"FMDB Error %d: %@", [fmDatabase lastErrorCode], [fmDatabase lastErrorMessage]);
    }
    // 取出查詢的結果集
    while ([resultSet next]) {
        VideoClass *video = [[VideoClass alloc] init];
        video.videoId               = [resultSet stringForColumn:@"videoid"];
        video.videoTitle            = [resultSet stringForColumn:@"songname"];
        video.videoDescription      = [resultSet stringForColumn:@"info"];
        video.coverFileName          = [resultSet stringForColumn:@"coverfilename"];
        video.path                   = [resultSet stringForColumn:@"urlpath"];
       
        [videoArray addObject:video];
    }
    [resultSet close];
}];

// 4. 關閉遊客 db
[fmDataQueue inDatabase:^(FMDatabase* fmDatabase) {
    if ([fmDatabase close]) {
        NSLog(@"close MYVIDEO succes ....");
    }
    else {
        NSLog(@"close MYVIDEO error");
    }
}];
[fmDataQueue close];
fmDataQueue = nil;

// 5. 打開 正式用戶 下的 db 文件(獲取遊客 db 路徑後,代碼同上打開 遊客 db)

// 6. 將 遊客 下載的video 數據插入到 正式用戶的 db 中
[fmDataQueue inTransaction:^(FMDatabase *db, BOOL *rollback) {
    [array enumerateObjectsUsingBlock:^(VideoClass  *video, NSUInteger idx, BOOL * _Nonnull stop) {
        [self insertOrUpdateCourse:video withDB:db];
        // 建立插入數據的 sql 語句
        NSString *insertSql = @"INSERT OR REPLACE INTO MYVIDEO (videoid,videoname,info,coverfilename,urlpath,) VALUES(?,?,?,?,?)";
        BOOL result = [fmDatabase executeUpdate:insertSql,
                    video.videoId,
                    video.videoTitle,
                    video.videoDescription,
                    video.coverFileName,
                    video.urlPath];
        if (!result) {
            NSLog(@"操蛋!插入 MYVIDEO data failed");
        } else {
            NSLog(@"牛逼!Insert MYVIDEO data success, U did it!");
        }
    }];
}];

// 7. 合併數據庫成功後,根據遊客 db 路徑,刪除 遊客 db 文件
NSFileManager *fm = [NSFileManager defaultManager];
BOOL success = [fm removeItemAtPath:fullPath error:&error];
if (error) {
      NSLog(@"怎麼會刪除失敗了,難道我姿式不對?delete file at path error:%@", error);
}

複製代碼
4.2 而後進行隱藏登陸界面,並調用一下以前傳進來的 block,繼續用戶以前的操做
- (void)hidePortalView {
    if (self.loginSucessBlock) {
        self.loginSucessBlock();
    }
    UIView animateWithDuration:0.2 animations:^{
        self.portalVC.view.alpha = 0;
    } completion:^(BOOL finished) {
        [self.portalVC.view removeFromSuperview];
        [self.portalVC removeFromParentViewController];
    }
}
複製代碼
5. 進行橫豎屏適配
  • 因爲帶有半透明背景的遮罩的視圖是以addChildViewController方式實現,所以自動適應父控制器的橫豎屏,這裏主要講一下再次點擊其餘登陸方式 進行帳號密碼輸入的傳統登陸註冊頁 的橫豎屏適配
- (void)signInWithAccountBtnTapped:(UIButton *)sender {
    SignInController *signInVC = [[SignInController alloc] initWithType:InputViewLogin];
    
    // 設置控制器的 modal 方式爲遵循當前控制器的環境,實現當前是橫(豎)屏就以橫(豎)屏方式modal
    signInVC.modalPresentationStyle = UIModalPresentationCurrentContext;
    
    [self presentViewController:signInVC animated:YES completion:nil];
}
複製代碼
  • 固然,在 SignInController 內部也要進行一些 UI 層級適配,在其 viewWillAppear 方法內部實現如下方法
// 根據狀態欄方向獲得當前頁面橫豎屏信息
UIDeviceOrientation deviceOrientation = (UIDeviceOrientation)[UIApplication sharedApplication].statusBarOrientation;
// 根據橫豎屏狀態,作出相應的 UI 層級調整,並作出相應標記
if (deviceOrientation == UIDeviceOrientationPortrait ||deviceOrientation ==
    UIDeviceOrientationPortraitUpsideDown) {
    [self doPortraitUIAdjustment];
    self.isLandScape = NO;
} else {
    [self doLandScapeUIAdjustment];
    self.isLandScape = YES;
}
複製代碼
  • 然鵝,跑一下代碼發現,雖然橫豎屏的展現沒錯了,但是點擊輸入框後,鍵盤仍是以豎屏的方式進行展示,由於咱們只是把 SignInController 的 modal 方式和 UI 適配作了,此時控制器自己並不知道本身是橫屏仍是豎屏,所以要重寫下面三個控制器方法
// 在橫屏狀態下,應該能夠隨設備重力感應進行 LandscapeRight 和 LandscapeLeft 兩個方向的自動翻轉
- (BOOL)shouldAutorotate {
    if (self.isLandScape) {
        return YES;
    } else {
        return NO;
    }
}

// 若是是橫屏狀態,應該支持 LandscapeRight 和 LandscapeLeft 兩個方向,豎屏狀態下只支持 Portrait
- (UIInterfaceOrientationMask)supportedInterfaceOrientations {
    if (self.isLandScape) {
        return UIInterfaceOrientationMaskLandscapeLeft | UIInterfaceOrientationMaskLandscapeRight;
    } else {
        return UIInterfaceOrientationMaskPortrait;
    }
}

// 默認的方向
-(UIInterfaceOrientation)preferredInterfaceOrientationForPresentation {
    if (self.isLandScape) {
        return UIInterfaceOrientationLandscapeRight;;
    } else {
        return UIInterfaceOrientationPortrait;
    }
}

#warning 至此,橫豎屏適配算是大功告成了
複製代碼

大概的思路就是這些,因爲跟項目相關性比較大,並且代碼實現方式也比較簡單,所以木有 demo,若是有其餘問題歡迎在留言區進行交流api

相關文章
相關標籤/搜索