iOS多重巢狀頁面

HasjOH發表於2018-02-23

先上作品圖

iOS多重巢狀頁面

UI樣式設計非原創,僅用於學習。

主要的功能點

  • 頭部檢視會跟隨移動,選擇介紹和攻略頁的segmentController又會一直保留在最頂部
  • 點選segmentController切換相應頁面,滑動頁面變更segmentController的index顯示
  • tableViewCell中巢狀UICollectionView
  • tableViewCell中的實現類似朋友圈“全文”和“收起”的效果

檢視結構分析

iOS多重巢狀頁面

最外層是scrollView和帶有segmentController的頭部檢視並列(為什麼scrollView的高度是這個值而不是減去頭部檢視的值,下文會補充)。 scrollView的contentView新增了左右兩個tableView,介紹頁(左),攻略頁(右)。介紹頁tableViewCell又巢狀了一個UIcollectionView。大致上層級就是這樣的。

一步一步來完成

頭部檢視和外層scrollView

自定義一個HeaderView的類,HeadView內部實現就不累贅了。需要的先放個原始碼 在ViewController中新增HeaderView和scrollView。


- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor whiteColor];
    [self.view addSubview:self.scrollView];
    [self.view addSubview:self.headerView];
}

- (HeaderView *)headerView {
    if (!_headerView) {
        CGFloat headerHeight = SCREEN_HEIGHT / 6 + SEGMENT_HEIGHT;
        _headerView = [[HeaderView alloc] initWithFrame:CGRectMake(0, 0, self.view.width, headerHeight)
                                                  title:@"王者榮耀"
                                              downloads:@"n+ 次下載"
                                               descripe:@"1.1 GB"];
        
        [_headerView.segmentedControl addObserver:self
                                       forKeyPath:@"selectedSegmentIndex"
                                          options:NSKeyValueObservingOptionNew
                                          context:nil];
    }
    return _headerView;
}

- (UIScrollView *)scrollView {
    if (!_scrollView) {
        self.scrollViewHeight = self.view.height - 64;
        _scrollView = [[UIScrollView alloc] initWithFrame:CGRectMake(0, 0, self.view.width, self.scrollViewHeight)];
        _scrollView.contentSize = CGSizeMake(self.view.width*2, 0);
        _scrollView.bounces = NO;
        _scrollView.showsHorizontalScrollIndicator = NO;
        _scrollView.pagingEnabled = YES;
        _scrollView.showsVerticalScrollIndicator = NO;
        _scrollView.delegate = self;
    }
    return _scrollView;
}
複製程式碼

segmentController和scrollView相互作用

上面程式碼已經設定了scrollView按頁滾動,點選segmentController的介紹頁,則讓scrollView滾動到左邊的頁面。反之亦然。這裡通過KVO來實現,也就是觀察segmentController的index值的變化來更改scrollView的contentOffset。KVO的釋放不能忘!

#pragma mark - KVO
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
        // segmentController選中不同按鈕切換scrollView的頁面
        if ([keyPath isEqualToString:@"selectedSegmentIndex"]) {
        // 點選介紹
        if (self.headerView.segmentedControl.selectedSegmentIndex == 0) {
                self.scrollView.contentOffset = CGPointZero;
            // 點選攻略
        } else {
            self.scrollView.contentOffset = CGPointMake(self.view.width, 0);
        }
    }
}

- (void)dealloc {
    [self.headerView.segmentedControl removeObserver:self forKeyPath:@"selectedSegmentIndex"];
}

複製程式碼

而當scrollView滾動時,用代理方法來更改segmentController的index。這裡當然也可以使用KVO的,為什麼使用代理方法會更合適?下文會提到。

#pragma mark - scrollView delegate
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    if (scrollView == self.scrollView) {
        // 滑動到介紹頁
        if (scrollView.contentOffset.x == 0) {
            if (self.headerView.segmentedControl.selectedSegmentIndex != 0) {
                self.headerView.segmentedControl.selectedSegmentIndex = 0;
            }
        // 滑動到攻略頁
        } else if (scrollView.contentOffset.x == self.view.width) {
            if (self.headerView.segmentedControl.selectedSegmentIndex != 1) {
                self.headerView.segmentedControl.selectedSegmentIndex = 1;
            }
        }
    }
}
複製程式碼

頭部檢視跟隨移動

實現思路:給表檢視新增一個空的headerView,大小和我們上面定義的頭部檢視一樣,如下圖。滾動表檢視的時候,根據表檢視的偏移量來設定真正的頭部檢視的y值到達到滾動的假象,並且同步左右兩個表檢視的偏移量,而segmentController滾動到頂部的時候便令其y值保持不變就不再滾動了。

還記得上文說的scrollView的高度為什麼不減去headerView的高度了嗎,看了這個圖理解了嗎。

另外一點,這裡採用KVO監聽tableView的contentOffset的值,變化後更改頭部檢視的y。所以KVO的keyPath是”contentOffset”。這就是上文提到的為什麼scrollView不使用KVO而使用代理方法。因為keyPath會衝突。

iOS多重巢狀頁面

接上面ViewController程式碼,為了讓程式碼更好地分離,這裡使用childViewController。

ViewController.m

- (void)viewDidLoad {
    // ...
    self.introduceTVC = [[IntroduceTableViewController alloc] init];
    self.strategyTVC = [[StrategyTableViewController alloc] init];
    [self setupChildViewController:self.introduceTVC x:0];
    [self setupChildViewController:self.strategyTVC x:SCREEN_WIDTH];
}

- (void)setupChildViewController:(UITableViewController *)tableViewController x:(CGFloat)x {
    UITableViewController *tableVC = tableViewController;
    tableVC.view.frame = CGRectMake(x, 0, self.view.width, self.scrollViewHeight);
    [self addChildViewController:tableVC];
    [self.scrollView addSubview:tableVC.view];
    [tableVC.tableView addObserver:self
                        forKeyPath:@"contentOffset"
                           options:NSKeyValueObservingOptionInitial
                           context:nil];
}

#pragma mark - KVO
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    // 頭部檢視移動跟隨滑動
    if ([keyPath isEqualToString:@"contentOffset"]) {
        CGFloat headerViewScrollStopY = SCREEN_HEIGHT/6 - 15;
        UITableView *tableView = object;
        
        CGFloat contentOffsetY = tableView.contentOffset.y;

        // 滑動沒有超過停止點
        if (contentOffsetY < headerViewScrollStopY) {
            self.headerView.y = - tableView.contentOffset.y;
            // 同步tableView的contentOffset
            for (UITableViewController *vc in self.childViewControllers) {
                if (vc.tableView.contentOffset.y != tableView.contentOffset.y) {
                    vc.tableView.contentOffset = tableView.contentOffset;
                }
            }
        } else {
            self.headerView.y = - headerViewScrollStopY;
        }
    }
複製程式碼

兩個childViewController中的方法

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.tableView.backgroundColor = DEFAULT_BACKGROUND_COLOR;
    self.tableView.showsVerticalScrollIndicator = NO;
    
    CGFloat headerHeight = SCREEN_HEIGHT / 6 + SEGMENT_HEIGHT;
    // 假的tableview,高度同GameDetailHeadView
    self.tableView.tableHeaderView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.view.width, headerHeight)];
}
複製程式碼

iOS多重巢狀頁面

tableViewCell巢狀collectionView

在介紹頁的第一個section中需要顯示一系列的介紹圖片,圖片有需要滾動,這時候巢狀UIcollectionView就很合適了。

自定義tableViewCell,cell中新增collectionView,由於初始化時大小並未確定下來,所以需要在layoutSubviews方法中設定collectionView的大小,讓其填充滿cell。

下面的...setCollectionViewDataSourceDelegate...方法可以幫助cell把相應的indexPath傳遞給collectionView,該專案並沒有使用到tableViewCell的indexPath,沒有也是可以的,只是為了增加其通用性,當有多個cell需要巢狀collectionView的時候,這時候就需要用indexPath來判斷具體是哪個section哪個row了。

這裡還有個坑[self.collectionView setContentOffset:CGPointZero animated:NO];方法不能用[self.collectionView setContentOffset:CGPointZero];代替,這是因為滾動過程中,很可能還未停下來,如果用了後面的方法,那麼設定contentOffset之後,collectionView還會持續把剛才未滾動完的繼續完成,位置就會出現偏差。

ImageTableViewCell.h

#import <UIKit/UIKit.h>

static NSString *CollectionViewCellID = @"CollectionViewCellID";

@interface ImageCollectionView : UICollectionView

// collectionView所在的tableViewCell的indexPath
@property (nonatomic, strong) NSIndexPath *indexPath;

@end

@interface ImageTableViewCell : UITableViewCell

@property (nonatomic, strong) ImageCollectionView *collectionView;

- (void)setCollectionViewDataSourceDelegate:(id<UICollectionViewDataSource, UICollectionViewDelegate>)dataSourceDelegate indexPath:(NSIndexPath *)indexPath;

@end

複製程式碼
ImageTableViewCell.m

@implementation ImageCollectionView

@end

@implementation ImageTableViewCell

- (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
    if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
        UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init];
        layout.sectionInset = UIEdgeInsetsMake(10, 10, 10, 10);
        layout.itemSize = CGSizeMake(SCREEN_WIDTH/3, SCREEN_HEIGHT/4);
        layout.scrollDirection = UICollectionViewScrollDirectionHorizontal;
        self.collectionView = [[ImageCollectionView alloc] initWithFrame:self.contentView.bounds collectionViewLayout:layout];
        
        [self.collectionView registerClass:[ImageCollectionViewCell class] forCellWithReuseIdentifier:CollectionViewCellID];
        self.collectionView.backgroundColor = [UIColor whiteColor];
        self.collectionView.showsHorizontalScrollIndicator = NO;
        [self.contentView addSubview:self.collectionView];
    }
    
    return self;
}

-(void)layoutSubviews {
    [super layoutSubviews];
    self.collectionView.frame = self.contentView.bounds;
}

/// 設定delegate,dataSource等
- (void)setCollectionViewDataSourceDelegate:(id<UICollectionViewDataSource, UICollectionViewDelegate>)dataSourceDelegate indexPath:(NSIndexPath *)indexPath {
    self.collectionView.dataSource = dataSourceDelegate;
    self.collectionView.delegate = dataSourceDelegate;
    self.collectionView.indexPath = indexPath;
    [self.collectionView setContentOffset:CGPointZero animated:NO];
    [self.collectionView reloadData];
}
複製程式碼

collectionViewCell的程式碼也不關鍵,不佔地方了,需要的看原始碼

之後在IntroduceTableViewController中設定相應的資料來源和代理方法。這裡有一個...willDisplayCell...方法,用來配置collectionView的,理論上裡面的操作也可以在...cellForRowAtIndexPath...完成,只是資料來源載入好當cell要顯示的時候再去執行配置collectionView的方法更符合邏輯一些。

IntroduceTableViewController.m

static NSString *kCellID0 = @"cellID0";
@interface IntroduceTableViewController () <UICollectionViewDelegate, UICollectionViewDataSource>
@end

#pragma mark - tableView dataSource
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    ImageTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:kCellID0];
    if (!cell) {
        cell = [[ImageTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:kCellID0];
    }
    return cell;
}

#pragma mark - tableView delegate
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    return SCREEN_HEIGHT / 3;
}

- (void)tableView:(UITableView *)tableView willDisplayCell:(ImageTableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
        [cell setCollectionViewDataSourceDelegate:self indexPath:indexPath];
}

#pragma mark - collection view deta source
-(NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
    return 5;
}

-(UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    ImageCollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:CollectionViewCellID forIndexPath:indexPath];
    if (!cell) {
        cell = [[ImageCollectionViewCell alloc] initWithFrame:CGRectMake(0, 0, SCREEN_HEIGHT/4, SCREEN_HEIGHT/4)];
    }
    cell.imageView.image = [UIImage imageNamed:@"fake_game"];
    return cell;
}

複製程式碼

tableViewCell中段落的“全文”和“收起”

實現思路: 這裡的文字採用UILabel來展示,收起狀態下,返回固定的cell高度,並且儲存初始的UILabel和UIButton的frame值。展開狀態下,根據需要顯示的文字計算其文字高度,根據高度來更改cell高度,還有其他控制元件的frame。 定義indexPath把自身所處的indexPath在控制器傳進來,點選按鈕後回撥showMoreBlock根據indexPath重新整理cell的內容和高度。所以這裡的...layoutSubview...方法很關鍵。

ContentTableViewCell.h

typedef void (^ShowMoreBlock)(NSIndexPath *indexPath);

@interface ContentTableViewCell : UITableViewCell

@property (nonatomic, copy) NSString *gameIntroduce; // 遊戲介紹內容
@property (nonatomic, strong) NSIndexPath *indexPath; // 用於重新整理指定cell
@property (nonatomic, assign, getter=isShowMoreContent) BOOL showMoreContent; // 是否顯示更多內容

@property (nonatomic, copy) ShowMoreBlock showMoreBlock; // 點選更多按鈕回撥

/// 預設高度(收起)
+ (CGFloat)cellDefaultHeight;

/// 顯示全文的高度
+ (CGFloat)cellMoreContentHeight:(NSString *)content;
複製程式碼
ContentTableViewCell.m

static const CGFloat kBlankLength = 10;

@interface ContentTableViewCell ()

@property (nonatomic, strong) UILabel *contentLabel;
@property (nonatomic, strong) UIButton *showMoreButton;
// 記錄button初始的frame
@property (nonatomic, assign) CGRect btnOriFrame;

@end

@implementation ContentTableViewCell

- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
    self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
    
    if (self) {
        self.contentLabel = [[UILabel alloc] initWithFrame:CGRectMake(kBlankLength, kBlankLength, SCREEN_WIDTH-kBlankLength*2, 100)];
        self.contentLabel.numberOfLines = 0;
        self.contentLabel.font = [UIFont systemFontOfSize:14];
        [self.contentView addSubview:self.contentLabel];
        
        self.showMoreButton = [UIButton buttonWithType:UIButtonTypeSystem];
        [self.showMoreButton setTitle:@"更多" forState:UIControlStateNormal];
        [self.showMoreButton addTarget:self
                                action:@selector(showMoreOrLessContent)
                      forControlEvents:UIControlEventTouchUpInside];
        [self.showMoreButton sizeToFit];
        CGFloat unFoldButtonX = SCREEN_WIDTH - kBlankLength - self.showMoreButton.width;
        CGFloat unFoldButtonY = kBlankLength * 2 + self.contentLabel.height;
        CGRect buttonFrame = self.showMoreButton.frame;
        buttonFrame.origin.x = unFoldButtonX;
        buttonFrame.origin.y = unFoldButtonY;
        self.showMoreButton.frame = buttonFrame;
        self.btnOriFrame = buttonFrame;
        [self.contentView addSubview:self.showMoreButton];
    }    
    return self;
}

- (void)layoutSubviews {
    [super layoutSubviews];
    if (self.isShowMoreContent) {
        // 計算文字高度
        NSDictionary *attribute = @{NSFontAttributeName: [UIFont systemFontOfSize:14]};
        NSStringDrawingOptions option = (NSStringDrawingOptions)(NSStringDrawingTruncatesLastVisibleLine | NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading);
        CGSize size = [self.gameIntroduce boundingRectWithSize:CGSizeMake(SCREEN_WIDTH-kBlankLength*2, 1000) options:option attributes:attribute context:nil].size;
        
        self.contentLabel.frame = CGRectMake(kBlankLength, kBlankLength, SCREEN_WIDTH-kBlankLength*2, size.height+20);
        
        CGFloat buttonMoreContentY = kBlankLength * 2 + self.contentLabel.height;
        CGRect buttonMoreContentRect = self.btnOriFrame;
        buttonMoreContentRect.origin.y = buttonMoreContentY;
        self.showMoreButton.frame = buttonMoreContentRect;
        [self.showMoreButton setTitle:@"收起" forState:UIControlStateNormal];
    } else {
        self.contentLabel.frame = CGRectMake(kBlankLength, kBlankLength, SCREEN_WIDTH-kBlankLength*2, 100);
        self.showMoreButton.frame = self.btnOriFrame;
        [self.showMoreButton setTitle:@"全文" forState:UIControlStateNormal];
    }
}

- (void)showMoreOrLessContent {
    if (self.showMoreBlock) {
        self.showMoreBlock(self.indexPath);
    }
}



- (void)setGameIntroduce:(NSString *)gameIntroduce {
    self.contentLabel.text = gameIntroduce;
    _gameIntroduce = gameIntroduce;
}


+ (CGFloat)cellDefaultHeight {
    return 160;
}

+ (CGFloat)cellMoreContentHeight:(NSString *)content {
    // 計算文字高度
    NSDictionary *attribute = @{NSFontAttributeName: [UIFont systemFontOfSize:14]};
    NSStringDrawingOptions option = (NSStringDrawingOptions)(NSStringDrawingTruncatesLastVisibleLine | NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading);
    CGSize size = [content boundingRectWithSize:CGSizeMake(SCREEN_WIDTH-kBlankLength*2, 1000) options:option attributes:attribute context:nil].size;
    return size.height + 80;
}
複製程式碼

**PS:**如果有留心觀察的童鞋會發現這裡的Label的內容在”全文”和“收起”狀態下的高度並不對齊,這是因為UILabel預設內容是居中對齊的。 嘗試過使用UITextField,但是內容只能顯示一行,棄用。 嘗試過使用UITextView,收起的內容末尾不會出現省略號,而且不是根據文字內容按行壓縮,可能會出現文字的一半被壓縮的情況,棄用。 找到的一種相對滿意的方法是自定義一個UILabel,重寫其...drawRect...方法,給需要的同學提供個思路。這裡不詳細展開了,挖個坑,之後計劃寫一篇和文字排版有關的可能會提到。

最後再把其他資料項填充一下,就是我們看到的這個樣子了

iOS多重巢狀頁面
是不是覺得少了點什麼,內容怎麼感覺都在一塊了。對了,是section headerView 和 footerView。

section header footer

這裡實現是不難,但是一樣要把控細節。我們的第一個展示圖片的cell是不需要headerView的,最後一個cell是不需要footerView的,這兩個就像畫蛇添足,有了反而不好看。這裡採用了偷懶的方式,直接把footerView的部分新增到了headerView的頭部。

iOS多重巢狀頁面
圖中兩個剪頭的位置分別是假的section footerView和section headerView

IntroduceTableViewController.m

- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section {
    UIView *backgroundView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, SCREEN_WIDTH, 40)];
    backgroundView.backgroundColor = [UIColor clearColor];
    SectionHeaderView *view = [[SectionHeaderView alloc] initWithFrame:CGRectMake(0, 10, self.view.width, 30)];
    
    if (section == 0) {
        return nil;
    }
    
    switch (section) {
        case CellTypeContentText:
            view.labelText = @"內容摘要";
            break;
        case CellTypeRelatedList:
            view.labelText = @"遊戲相關";
        default:
            break;
    }
    [backgroundView addSubview:view];
    return backgroundView;
}
複製程式碼

iOS 11下的一個小問題

iOS多重巢狀頁面
在介紹頁點選“收起”和“全文”後移動到攻略頁的時候會發現,內容向下偏移了。 找了一下原因是點選按鈕的時候會reload cell中的資料,導致tableView的偏移量發生的變化。下面引用來自騰訊Bugly

Self-Sizing在iOS11下是預設開啟的,Headers, footers, and cells都預設開啟Self-Sizing,所有estimated 高度預設值從iOS11之前的 0 改變為UITableViewAutomaticDimension。

如果目前專案中沒有使用estimateRowHeight屬性,在iOS11的環境下就要注意了,因為開啟Self-Sizing之後,tableView是使用estimateRowHeight屬性的,這樣就會造成contentSize和contentOffset值的變化,如果是有動畫是觀察這兩個屬性的變化進行的,就會造成動畫的異常,因為在估算行高機制下,contentSize的值是一點點地變化更新的,所有cell顯示完後才是最終的contentSize值。因為不會快取正確的行高,tableView reloadData的時候,會重新計算contentSize,就有可能會引起contentOffset的變化。

這時候需要在tableViewController中新增如下即可解決。

    self.tableView.estimatedRowHeight = 0;
    self.tableView.estimatedSectionFooterHeight = 0;
    self.tableView.estimatedSectionHeaderHeight = 0;
複製程式碼

就當認為已經解決的時候,問題再次出現。

又一個問題誕生

對tableView進行快速向下滾動操作,會出現和上圖一樣的情況,而且灰色區域每次都不一致。於是快速滾動一次,列印出了資料。

iOS多重巢狀頁面

這裡可以很明顯看出精度很不精確,keyPath無法獲取準確的停止點,因此同步兩個頁面的tableView的contentOffset會不準確。這裡需要的精度至少是1。 原本以為...scrollViewDidScroll...可以獲取到想要的精度,嘗試之後發現也不可行。 後來想到一種不太優雅卻能解決問題的方法,既然還未顯示的tableView的contentOffset會偏下,那麼如果小於頭部檢視的y值,就直接設定成這個值就好了。修改方法為

if ([keyPath isEqualToString:@"contentOffset"]) {
        CGFloat headerViewScrollStopY = (int)SCREEN_HEIGHT/6 - 15.0;
        UITableView *tableView = object;
        CGFloat contentOffsetY = tableView.contentOffset.y;
        // 滑動沒有超過停止點,頭部檢視跟隨移動
        if (contentOffsetY < headerViewScrollStopY) {
            self.headerView.y = - tableView.contentOffset.y;
            // 同步tableView的contentOffset
            for (UITableViewController *vc in self.childViewControllers) {
                if (vc.tableView.contentOffset.y != tableView.contentOffset.y) {
                    vc.tableView.contentOffset = tableView.contentOffset;
                }
            }
        // 頭部檢視固定位置
        } else {
            self.headerView.y = - headerViewScrollStopY;
            // 解決高速滑動下tableView偏移量錯誤的問題
            if (self.headerView.segmentedControl.selectedSegmentIndex == 0) {
                UITableViewController *vc = self.childViewControllers[1];
                if (vc.tableView.contentOffset.y < headerViewScrollStopY) {
                    CGPoint contentOffset = vc.tableView.contentOffset;
                    contentOffset.y = headerViewScrollStopY;
                    vc.tableView.contentOffset = contentOffset;
                }
            } else {
                UITableViewController *vc = self.childViewControllers[1];
                if (vc.tableView.contentOffset.y < headerViewScrollStopY) {
                    CGPoint contentOffset = vc.tableView.contentOffset;
                    contentOffset.y = headerViewScrollStopY;
                    vc.tableView.contentOffset = contentOffset;
                }
            }
        }
    }
複製程式碼

##末 原始碼和部落格相輔相成,部落格幫助理解,原始碼更具有結構性。推薦下載原始碼來看一下幫助理解。覺得有幫助的希望來個star。 原始碼地址:github.com/HasjOH/Nest…

參考: ashfurrow.com/blog/puttin… mp.weixin.qq.com/s/AZFrqL9dn… stackoverflow.com/questions/1…

相關文章