「iOS」高仿【少數派】客戶端 程式碼+思路講解

halohily發表於2017-11-26

少數派
少數派

一、寫在前面

在我的iOS開發學習過程中,閱讀過許多同學的高仿專案文章、原始碼,對我助益頗深。但是許許多多的高仿專案在技術方面各有側重,所以我先把本專案中值得探討的技術點列出,方便正好需要的同學。

本專案重點探討:

  • UITableview的效能優化
  • UIScrollView的進階使用
  • 少數派客戶端導航欄動態效果的實現
  • UITableview的多種控制元件巢狀
  • 手動封裝一些常用的檢視控制元件

二、簡述

首先來看一下專案的執行效果:

LYSSPai執行展示
LYSSPai執行展示

對於原客戶端的一些重複性細節沒有全部實現,歡迎大家fork。

這裡是 LYSSPai專案地址

在本文中,我會先介紹專案的整體實現思路,然後對於開發過程中遇到的值得探討的點進一步講述。

專案中的資料來源為使用Charles抓包獲取,用json檔案存在bundle中。
專案中的素材來源為官方客戶端ipa包使用iOS Images Extractors解析獲得。
宣告:僅用於學習交流,嚴禁用於商業用途。

三、整體實現思路

在這一節,我會按照頁面來介紹整體開發思路。

1. 首頁

首頁展示-1
首頁展示-1

首頁展示-2
首頁展示-2

1.1 頁面簡述

  • 這是專案的首頁,主要結構是頂部的導航欄和下面的內容。
  • 導航欄效果:
    在頁面向上滑動時,頂部導航欄的文字、按鈕尺寸會隨之動態減小,而後整體上移,懸停在頂部,模擬系統的導航欄效果。當頁面下滑時,效果相反。
  • 內容展示部分:
    首先有一個左右滑動的類似輪播圖部分。用以展示重點推薦的專題、文章、廣告等。
    接下來是一篇文章。
    然後又是一個手動滑動的類似輪播圖。用來展示付費的欄目。
    剩餘部分全為文章。

1.2 實現思路

1.2.1 內容展示

使用UITableview,包含三種cell。
輪播圖為橫向的UIScrollView,為其中的每一個子cell設定tag值,點選事件以delegate的方式交由首頁VC實現。
文章展示cell為普通的cell。右上角的選單按鈕點選事件以delegate的方式交由首頁VC實現。

1.2.2 導航欄實現

導航欄的動態效果需要隨著內容滑動而進行,而後懸停在頂部。其中涉及導航欄的高度變化以及懸停效果
我們很容易想到使用UITableView的tableHeadersectionHeader,那麼先來明確一下這兩種檢視的特性:
tableHeader沒有頂部懸停效果,但是可以方便地更改檢視的高度:

CGRect newFrame = headerView.frame;
newFrame.size.height = newFrame.size.height + webView.frame.size.height;
headerView.frame = newFrame;

//beginUpdates和endUpdates方法用來以動畫形式更改高度
[self.tableView beginUpdates];

//要更改tableHeader,必須顯式呼叫set方法
[self.tableView setTableHeaderView:headerView];

[self.tableView endUpdates];複製程式碼

而sectionHeader是預設帶有懸停效果的,但是我沒有找到可以高效更新檢視高度的方法,所以這種方法果斷放棄。
對於tableHeader的懸停效果,可以在頁面滑到臨界點時,將tableHeader加入到與tableview同一層級的view中,手動實現懸停效果,這也是許多UIScrollView的子View想要實現頁面懸停效果的方式。
但是有一點需要知道,UITableView是一個龐大的物件,對它頻繁更新勢必會影響效能。而動態更改tableHeader時,會不停地改變整個UITableView的佈局。為了一個小小的動態效果實在不必如此。所以,我使用一個單獨的view作為頂部的導航欄,並且將它和tableview加入到同一個容器scrollview中。這樣動態效果僅僅影響這個單獨的view佈局。

1.2.3 分類專題頁

分類專題頁
分類專題頁

點選首頁右上角的按鈕或者在內容cell中左劃,會進入分類專題頁面。這個頁面只是簡單模擬實現了一下。

1.2.4 文章閱讀頁面

文章閱讀頁
文章閱讀頁

點選文章cell或者輪播部分的文章型別子cell,會進入對應的文章閱讀頁面。
這個頁面底部導航欄為手動模擬實現。文章展示使用WKWebView。在整個頁面包含web內容部分,均可以右劃返回。

關於使用WebView展示內容的探討,在我的文章從簡書iOS客戶端,來談談Hybrid方案細節設計進行了詳細探討,歡迎大家閱讀。

2.發現

發現頁展示
發現頁展示

這個頁面和首頁類似,並且比首頁簡單,略過不表。

3.訊息

訊息頁展示
訊息頁展示

這個頁面沒有特別複雜的部分。不過自己封裝了選擇器View,效果和原客戶端完全一致,需要的同學可以閱讀這部分程式碼。其中涉及到UIScrollView的一些進階特性,一會會詳述。

四、重點詳述

1. tableview效能優化

  • 優化場景
    頁面開發完成後,cell巢狀scrollview,其中還包括多個子cell,如果不加優化的話,可以預見使用體驗不會太好。在第一次滑動到第二個輪播圖時,很明顯感受到頁面fps下降。而後滑動流暢,fps基本保持在60。所以我們知道,優化重點在於輪播圖的首次載入、渲染。輪播圖首次出現在螢幕範圍中之後,被加入快取,所以再次滑到這裡時便不會卡頓。
    說到效能優化,不得不推薦一下ibireme的文章,強烈建議沒看過的同學認真閱讀一下iOS 保持介面流暢的技巧
  • 優化思路
    滑動頁面時fps在60左右時,使用者不會感覺到卡頓,這是優化的目標。也就是說,我們需要在1s/60 = 16.7ms內,完成每一幀的渲染。而檢視渲染需要CPU運算+GPU渲染運算共同完成。所以我們需要分析在這個場景下,CPU與GPU各自的工作量,合理調配,從而使它們的每一幀運算耗時總和低於16.7ms。
  • cell重用
    cell重用是非常基礎但又非常重要的優化手段,正確使用tableview的cell重用機制。
  • cell高度快取
    tableview的渲染過程中,有多少個cell,就會呼叫多少次- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath方法,從而確定contentSize。所以,儘量將cell的高度提前計算並且進行快取,避免在這個代理方法中進行計算,可以有效優化tableview的渲染。
  • 佈局計算優化

    佈局的計算是CPU的工作,當頁面層級複雜時,佈局計算就會耗費較多時間。同時,應該明確的一點是使用Masonry自動佈局是將佈局計算量交給CPU去完成,勢必會相對增加耗時。所以,在複雜cell的優化中,一般建議手動計算佈局,會稍微提升一些效能。除此之外,如果頁面佈局計算量比較大的話,將佈局計算在頁面渲染之前完成並且快取,會有效減少檢視渲染時的16.7ms中的CPU運算時間。
    在本專案中,我為輪播圖cell封裝了一個frameModel,在頁面資料獲取完成後,提前計算輪播圖的佈局結果,在頁面渲染時,無需計算便可以直接賦值。

    //count為輪播圖子cell數量
    +(instancetype)PaidNewsFrameModelWithCount:(NSInteger)count
    {
      PaidNewsFrameModel *model = [[self alloc] init];
    
      float cellWidth = LYScreenWidth * 0.55;
      float cellHeight = LYScreenWidth * 0.7;
      model.cellTitleFrame = CGRectMake(25, 10, 100, 18);
      model.moreFrame = CGRectMake(LYScreenWidth - 65, 11, 40, 16);
      model.backScrollViewFrame = CGRectMake(0, 43, LYScreenWidth, cellHeight);
      model.paidNewsViewFrames = [[NSMutableArray alloc] init];
      model.paidTitleFrames = [[NSMutableArray alloc] init];
      model.avatorFrames = [[NSMutableArray alloc] init];
      model.nicknameFrames = [[NSMutableArray alloc] init];
      model.updateInfoFrames = [[NSMutableArray alloc] init];
    
      for ( int i = 0; i < count; i++)
      {
          NSValue *paidNewsViewFrame = [NSValue valueWithCGRect:CGRectMake(25 + (cellWidth + 15) * i, 0, cellWidth, cellHeight)];
          [model.paidNewsViewFrames addObject:paidNewsViewFrame];
          NSValue *avatorFrame = [NSValue valueWithCGRect:CGRectMake(15, cellHeight - 90, 20, 20)];
          [model.avatorFrames addObject:avatorFrame];
          NSValue *nicknameFrame = [NSValue valueWithCGRect:CGRectMake(45, cellHeight - 85, cellWidth - 75, 12)];
          [model.nicknameFrames addObject:nicknameFrame];
          NSValue *updateInfoFrame = [NSValue valueWithCGRect:CGRectMake(15, cellHeight - 50, cellWidth - 30, 12)];
          [model.updateInfoFrames addObject:updateInfoFrame];
      }
      return model;
    }複製程式碼

    可以看到,帶有for迴圈並且每一個迴圈體都稍有計算量,將這些計算工作提前並且在子執行緒執行是非常明智的。我們要讓那16.7ms“用在刀刃上”。

  • 正確選擇檢視控制元件,為檢視瘦身
    UIViewCALayer的關係大家應該都有所瞭解。UIView在CALayer的基礎上,封裝了互動操作相關的部分,UIView是比CALayer更重量的。如果當前控制元件不需要響應使用者操作,我們應該儘可能使用CALayer替代UIView。
    在本專案中,付費內容輪播圖部分,整個子cell需要響應使用者的點選操作。所以只需要在子cell的最底層view新增手勢識別。而背景圖片、使用者頭像等元素是不需要響應特殊操作的,所以這些控制元件不使用UIImageView,改用CALayer。其實文字部分,也可以不使用UILabel,這是可以繼續優化的部分。
    這是頭像部分的佈局程式碼:
    CALayer *avator = [[CALayer alloc] init];
    [paidNewsView.layer addSublayer:avator];
    NSValue *avatorFrame = self.model.paidNewsFrame.avatorFrames[i];
    avator.frame = avatorFrame.CGRectValue;
    [avator yy_setImageWithURL:[NSURL URLWithString:self.model.PaidNewsData[i][@"avatar"]] placeholder:nil options:kNilOptions progress:nil transform:^UIImage * _Nullable(UIImage * _Nonnull image, NSURL * _Nonnull url) {
      image = [image yy_imageByRoundCornerRadius:40.0];
      return image;
    } completion:nil];複製程式碼
  • 網路內容非同步載入
    待頁面顯示出來之後,網路內容再慢慢載入,也是為了將時間用在刀刃上。
    非同步載入網路圖片的框架,有大家都熟知的SDWebImage,也有ibiremeYYWebImage。據介紹YYWebImage的效能是要比SD好一些的,這個我沒有親自驗證。
    這裡我使用了YYWebImage:
    [avator yy_setImageWithURL:[NSURL URLWithString:self.model.PaidNewsData[i][@"avatar"]] placeholder:nil options:kNilOptions progress:nil transform:^UIImage * _Nullable(UIImage * _Nonnull image, NSURL * _Nonnull url) {
      image = [image yy_imageByRoundCornerRadius:40.0];
      return image;
    } completion:nil];複製程式碼
  • 圓角設定

    又是老生常談的圓角設定。使用CALayer的相關屬性來實現圓角效果會觸發離屏渲染,增加GPU的工作量。在這一點的優化上,可以使用CPU將圖片素材直接裁剪為圓角圖片再進行顯示。當然,最優的方案當然是讓你們的美工直接提供圓角素材~
    這裡我直接使用了YYImage的圓角處理。

2. UIScrollView的進階使用

這個部分我主要講的是訊息頁面的選擇器控制元件封裝的思路。
先看效果:

selectView效果展示
selectView效果展示

一個非常簡單的控制元件。但是有一個細節需要注意:使用輕劃手勢左右滑動時,頁面必然進行滾動。而使用拖拽時,則會判斷拖拽範圍來決定是否進行滾動。
這個效果我使用了UIScrollView的代理方法- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate來實現。
這裡是程式碼:

//停止拖拽時的代理
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
{
//    如果是內容頁的橫向滑動
    if (scrollView == self.contentView)
    {
        NSLog(@"slowing?? %@",decelerate ? @"YES" : @"NO");
        CGFloat scrollX = scrollView.contentOffset.x;
//        如果帶有慣性(快速滑動),則內容頁必然進行對應的移動
        if (decelerate)
        {
            if (self.selectedTag == 0 && scrollView.contentOffset.x > 0)
            {
                self.selectedTag = 1;
            }
            else if (self.selectedTag == 1 && scrollView.contentOffset.x < LYScreenWidth)
            {
                self.selectedTag = 0;
            }
        }
//        如果無慣性(慢速拖拽),此時需要滿足拖動的範圍才會進行移動
        else
        {
            if (self.selectedTag == 0 && scrollX >= 0.5 * LYScreenWidth)
            {
                self.selectedTag = 1;
            }
            else if (self.selectedTag == 1 && scrollX <= 0.5 * LYScreenWidth){
                self.selectedTag = 0;
            }
        }
        [self contentViewScrollAnimation];
    }
}複製程式碼

當輕劃頁面時,scrollview是有慣性的,而拖拽時是沒有慣性的,利用這個特性來進行相應的判斷。
這裡是小橫條移動的動畫:

//內容頁進行移動的封裝
- (void)contentViewScrollAnimation
{
    //根據此時選中的按鈕計算出contentView的偏移量
    CGFloat offsetX = self.selectedTag * LYScreenWidth;
    CGPoint scrPoint = self.contentView.contentOffset;
    scrPoint.x = offsetX;
    //預設滾動速度有點慢 加速了下
    [UIView animateWithDuration:0.3 animations:^{
        [self.contentView setContentOffset:scrPoint];
    }];
//    通知選擇器,進行小橫條的移動
    [self.selectView selectBtnChangedTo:self.selectedTag];
}複製程式碼

3. 導航欄動態效果的實現

先重新看一下效果:

導航欄效果展示
導航欄效果展示

這裡使用scrollview的代理方法- (void)scrollViewDidScroll:(UIScrollView *)scrollView來實現。
這是程式碼的部分:

 //    scrollview剛剛開始滑動,此時導航標題大小和按鈕大小進行變化
    if (Y <= -97 && Y > -130)
    {
        //            以字號為36和20計算得出的臨界Y值為-97和-130,根據此刻Y值計算此時的字號
        CGFloat fontSize = (-((16.0 * Y)/33.0)) - 892.0/33.0;
        self.titleLabel.font = [UIFont fontWithName:@"HelveticaNeue-Bold" size:fontSize];
        //            NSLog(@"point:: %f",self.titleLabel.font.pointSize);
        //            更新titlelabel的高度約束
        [self.titleLabel mas_updateConstraints:^(MASConstraintMaker *make) {
            make.height.mas_equalTo(self.titleLabel.font.pointSize + 0.5);
        }];
        //            計算此刻button的對應尺寸,若大於最小值(16),則更新約束
        CGFloat buttonSize = self.titleLabel.font.pointSize * (5.0/9.0);
        if (buttonSize >= 16.0)
            [self.button mas_updateConstraints:^(MASConstraintMaker *make) {
                make.width.mas_equalTo(buttonSize);
                make.height.mas_equalTo(buttonSize);
            }];
    }複製程式碼

這裡計算比較繁瑣,可以仔細看一下。

4. UITableview的多種控制元件巢狀

這個部分內容在前文的頁面實現部分已經簡單講過,這裡列出來是提醒初學的朋友可以稍作留意。

5. 手動封裝一些常用的檢視控制元件

在本專案中,我封裝了頁面的導航欄檢視HeaderView,選擇器檢視SelectView以及頁面的載入loading檢視LYLoadingView。需要了解的同學可以留心看一些。
這裡簡單展示一下loading檢視的封裝。
這是標頭檔案部分:

@interface LYLoadingView : UIView
//隱藏傳入view中的loadingview
+ (BOOL)hideLoadingViewFromView:(UIView *)view;
//為傳入view顯示一個loadingview
+ (BOOL)showLoadingViewToView:(UIView *)view WithFrame:(CGRect)frame;
@end複製程式碼

這是實現部分:

+ (BOOL)hideLoadingViewFromView:(UIView *)view
{
    NSEnumerator *subviewsEnum = [view.subviews reverseObjectEnumerator];
    for (UIView *subview in subviewsEnum)
    {
        if([subview isKindOfClass:self])
        {
            [subview removeFromSuperview];
            return YES;
        }
    }
    return NO;
}

+ (BOOL)showLoadingViewToView:(UIView *)view WithFrame:(CGRect)frame
{
    LYLoadingView *loadingView = [[LYLoadingView alloc] initWithFrame:frame];
    loadingView.backgroundColor = [UIColor whiteColor];
    UIActivityIndicatorView *indicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray];
    indicator.center = CGPointMake(frame.size.width/2, frame.size.height/2 - 100);
    [indicator startAnimating];
    [loadingView addSubview:indicator];
    [view addSubview:loadingView];
    return YES;
}複製程式碼

loading檢視模仿官方app的一個簡單菊花指示器。
使用時,在頁面渲染最開始在檢視上加一個loadingview:

//    初始化loadingview
CGRect loadingViewFrame = CGRectMake(0, 130, LYScreenWidth, LYScreenHeight - 130);
[LYLoadingView showLoadingViewToView:self.view WithFrame:loadingViewFrame];複製程式碼

頁面資料獲取完成後,table進行reload,然後移除loading檢視:

[self.newsTableView reloadData];
//        隱藏loadingview
[LYLoadingView hideLoadingViewFromView:self.view];複製程式碼

五、寫在最後

這個專案並沒有100%完全復原官方客戶端,筆者閒暇時間不允許,所以算是倉促結束,並且寫了這篇文章作結尾。專案中還存在一些bug,也有未完成的功能點,歡迎大家fork。
有不足之處歡迎大家指出,也歡迎討論專案中的其他實現方式,希望幫助到需要的同學。

最後再貼一下 LYSSPai專案地址。如果覺得不錯,希望點個star~

halo

相關文章