iOS開發進階:性能優化與穩定性優化實踐

優化實踐主要包括UI界面的優化、穩定性的優化兩部分,是在開發過程中對於相關問題的認知和解決方案,僅代表個人觀點,如有疑問,歡迎一起探討學習。


一、UI界面優化

在渲染流程中GPU、CPU、顯示器協同工作。CPU計算好顯示的內容(包括視圖的創建、佈局計算、圖片解碼、文本繪製等),再提交打給GPU進行變換、圖層合成、紋理渲染,並將渲染的結果提交到幀緩衝區,等帶下一次VSync信號顯示到屏幕上。

針對這個問題,可以分別對CPU、GPU做一些方面的優化:

  • 針對CPU的優化:在子線程進行對象的創建、調整、銷燬;在子線程中預排版、預渲染;異步繪製等等
  • 針對GPU的優化:避免離屏渲染,減少塗層的複雜度等。
1.界面佈局優化之預排版

UITableViewUICollectionView中單元格的現實需要提供給代理方法對應的高度),以快速決定後續單元格佈局的位置,而單元格高度與實際渲染的數據相關。我們可以在heightForRow(...)cellForRow(...)方法中通過臨時佈局計算單元格的高度和實際數據的渲染,但是這樣一來就進行了多次佈局計算,如果界面非常複雜,這裏勢必會出現卡頓。

  • 解決方案:網絡數據返回後進行佈局運算,生成數據模型。比如複雜業務邏輯的判斷、圖像顯示的frame、文本顯示的frame、單元格height、富文本拼接、摺疊展開數據的計算。計算完畢之後再切換到主線程刷新用戶界面。1.heightForRow(...)中通過遍歷找到模型,返回模型上計算好的高度。2.cellForRow(...)方法中使用計算好的模型進行用戶界面的佈局顯示。
  • 優缺點:一次佈局計算完成後後續可以直接取出來進行渲染,避免多次佈局計算,體改滑動渲染的效率。如果數據量特別大,可以在預估數據能覆蓋整個屏幕的情況下切換到主線程進行界面的渲染,計算完成後再刷新一下用戶界面。


2、界面渲染優化之預解碼/預渲染

圖像的現實需要網絡獲取到圖片的Data-Buffer,再解碼生成Image-Buffer,而整個解碼的過程是比較耗費性能的。如果有大量的網絡圖片需要加載,這裏可能就會造成一定程度的卡頓。

  • 解決方案:網絡圖片在子線程中提前解碼,然後將解碼後的數據綁定在模型上。顯示的時候直接設置模型中的數據。

關於這一點在創建的圖片加載網絡框架中都是有跡可循的,比如在SDWebImage框架中SDWebImageDownloaderOperation中:

//@property (strong, nonatomic, nonnull) NSOperationQueue *coderQueue; // the serial operation queue to do image decoding

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
    
    [self.coderQueue addOperationWithBlock:^{
        // decode the image in coder queue, cancel all previous decoding process
        UIImage *image = SDImageLoaderDecodeImageData(imageData, self.request.URL, [[self class] imageOptionsFromDownloaderOptions:self.options], self.context); 
    }]; 
}


3、界面渲染優化之異步繪製

根據CPU、GPU渲染原理,由於垂直同步的機制,如果在一個 VSync 時間內,CPU 或者 GPU 沒有完成內容提交,則那一幀就會被丟棄,等待下一次機會再顯示,而這時顯示屏會保留之前的內容不變。這就是界面卡頓的原因。從上圖中可以看到,CPU 和 GPU 不論哪個阻礙了顯示流程,都會造成掉幀現象。

整個流程可以通過如下代碼進行驗證:

@interface NXAsyncableLayer : CALayer
@end
@implementation NXAsyncableLayer
- (void)setNeedsDisplay {
    NSLog(@"%s", __func__);
    [super setNeedsDisplay];
}

- (void)display {
    NSLog(@"%s", __func__);
    [super display];
}

- (void)drawInContext:(CGContextRef)ctx {
    NSLog(@"%s", __func__);
    [super drawInContext:ctx];
}

- (void)renderInContext:(CGContextRef)ctx{
    NSLog(@"%s", __func__);
    [super renderInContext:ctx];
}
@end

@interface NXAsyncableLabel : UILabel
@end
@implementation NXAsyncableLabel
- (void)setNeedsDisplay {
    NSLog(@"%s", __func__);
    [super setNeedsDisplay];
}

- (void)setNeedsDisplayInRect:(CGRect)rect{
    NSLog(@"%s", __func__);
    [super setNeedsDisplayInRect:rect];
}

- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx {
    NSLog(@"%s", __func__);
    [super drawLayer:layer inContext:ctx];
}

- (void)drawRect:(CGRect)rect {
    NSLog(@"%s", __func__);
    [super drawRect:rect];
}

- (void)displayLayer:(CALayer *)layer {
    NSLog(@"%s", __func__);

    dispatch_async(dispatch_get_global_queue(0, 0), ^{
       //1.異步繪製,切換至子線程
       UIGraphicsBeginImageContextWithOptions(size, NO, scale);
       //2.獲取當前上下文
       CGContextRef context = UIGraphicsGetCurrentContext();
       //3.進行異步繪製(略)
       //4.生成位圖
       UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
       UIGraphicsEndImageContext();

       dispatch_async(dispatch_get_main_queue(), ^{
           //5.子線程完成工作,切換至主線程
           self.layer.contents = (__bridge id)image.CGImage;
        });
   });
}

+ (Class)layerClass {
    return [NXAsyncableLayer class];
}
@end
  • 測試代碼
NXAsyncableLabel *label = [[NXAsyncableLabel alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
label.text = @"NXAsyncableLabel";
[self.view addSubview:label];
  • [NXAsyncableLabel displayLayer]打開,打印結果爲:
-[NXAsyncableLabel setNeedsDisplay]
-[NXAsyncableLayer setNeedsDisplay] 
-[NXAsyncableLayer display]
-[NXAsyncableLabel displayLayer:]
  • [NXAsyncableLabel displayLayer]屏蔽掉,打印結果爲:
-[NXAsyncableLabel setNeedsDisplay]
-[NXAsyncableLayer setNeedsDisplay]
-[NXAsyncableLayer display]
-[NXAsyncableLayer drawInContext:]
-[NXAsyncableLabel drawLayer:inContext:]
-[NXAsyncableLabel drawRect:]
  • [NXAsyncableLabel displayLayer]屏蔽掉並且將NXAsyncableLabel.layer.delegate設置爲nil,打印結果爲:
-[NXAsyncableLabel setNeedsDisplay]
-[NXAsyncableLayer setNeedsDisplay]
-[NXAsyncableLayer display]
-[NXAsyncableLayer drawInContext:]
如上歸納總結如下:
  • [UIView setNeedsDisplay]會調用[CALayer setNeedsDisplay]方法,並並打上標記。在runloop將要結束的時候調用[CALayer display]方法。
  • 接下來判斷是否實現了[UIView displayLayer:]
  • 如果實現了則調用[UIView displayLayer:],最終生成一張位圖,賦值給layer.contents,完成自定義繪製流程。這裏的繪製可以在子線程中完成,生成位圖後再切換到子線程設置layer.contents。(CGBitmapContextCreate創建位圖、CoreGraphic繪製、CGBitmapContextCreatImage生成CGImage圖片).
  • 如果沒有則調用[CALayer drawInContext:],如果CALayer.delegate不爲空繼續調用[UIView drawLayer:inContext:][UIView drawRect:]完成系統默認的同步繪製流程。

假如視圖非常複雜(子視圖較多、佈局相互依賴、有大量圖片需要解碼),那麼這個CPU+GPU的工作就可能超過1幀的時間,這樣在快速滑動的過程中就會造成卡頓,接口給我們提供了優化的空間,也就是在[UIView displayLayer:]中,自己進行計算佈局和繪製,整個過程中我們可以放在子線程中進行,不影響主線程處理滑動等其他的UI事務,這樣就不會卡頓。需要補充一點,如果有大量的計算在整個滑動的過程中有時候會出現局部的空白,這是正常的,畢竟計算佈局是在子線程中異步操作的,如果沒有計算完畢則渲染出來的就會沒有內容。

詳細的繪製流程和原理可以參考YYKit開源框架YYAsyncLayerYYLabel的實現流程。

4、界面渲染優化之離屏渲染

離屏渲染的檢測方式:選中模擬器 ->Debug -> Off Off-screen Rendered,如果使用離屏渲染會有黃色的背景,比如系統的電池。

GPU的渲染分爲當前屏幕渲染(On-Screen Rendering)和離屏渲染(Off-Screen Rendering)。當前屏幕渲染的原理上面已經介紹了,在一次Vsync信號週期內CPU計算好佈局等,然後將計好的內容交給GPU渲染。GPU渲染好之後就會放入幀緩衝區。所謂離屏渲染就是指GPU在當前屏幕的幀緩衝區意外開闢一個新的緩衝區進行渲染操作。

那爲什麼說離屏渲染耗費性能的呢?

  • 創建新的緩衝區:要想進行離屏渲染,首先需要創建一個新的緩衝區。
  • 上下文切換:離屏渲染的整個過程,需要多次切換上下文環境。先是從當前屏幕切換到離屏緩衝區,渲染結束後將離屏緩衝區的數據渲染到屏幕上有需要從離屏緩衝區切換到當前屏幕。

造成離屏渲染的原因有很多,比如shouldRasterize(光柵化)、mask(遮罩層)、shadows(陰影)、EdgeAnntialiasing(抗鋸齒)、cornerRadius(圓角)等等

  • 爲圖層設置遮罩layer.mask
  • 將圖層的layer.masksToBounds / view.clipsToBounds屬性設置爲true
  • 將圖層layer.allowsGroupOpacity屬性設置爲YES和layer.opacity小於1.0
  • 爲圖層設置陰影layer.shadow *
  • 爲圖層設置layer.shouldRasterize = true
  • 具有layer.cornerRadiuslayer.edgeAntialiasingMasklayer.allowsEdgeAntialiasing的圖層
  • 文本(任何種類,包括UILabelCATextLayerCore Text等)
  • 使用CGContextdrawRect :方法中繪製大部分情況下會導致離屏渲染,甚至僅僅是一個空的實現
  • iOS 9.0之前UIimageViewUIButton設置圓角都會觸發離屏渲染。
  • iOS 9.0之後UIButton設置圓角會觸發離屏渲染,而UIImageViewpng圖片設置圓角不會觸發離屏渲染了,如果設置其他陰影效果之類的還是會觸發離屏渲染的。

針對以上問題我們針對性的做出優化方案:

  • 關於圓角的處理方案,使用CAShapeLayer+UIBezierPath方案來蓋住圓角部分。CAShapeLayer使用GPU渲染,專業的人做專業的事情,效率更高。
UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
imageView.image = [UIImage imageNamed:@"test.png"];

UIBezierPath *maskPath = [UIBezierPath bezierPathWithRoundedRect:imageView.bounds byRoundingCorners:UIRectCornerAllCorners cornerRadii:imageView.bounds.size];
    
CAShapeLayer *maskLayer = [[CAShapeLayer alloc] init];
maskLayer.frame = imageView.bounds;
maskLayer.path = maskPath.CGPath;
imageView.layer.mask = maskLayer;
[self.view addSubview:imageView];

或者採用YYImage中對圖片圓角邊框的處理方式,內部處理了圓角和邊框(邊框寬度、顏色)等多種需求,內部使用CoreGraphics+UIBezierPath的方案,繪製圓角、繪製邊框,生成新的圖片。

- (UIImage *)imageByRoundCornerRadius:(CGFloat)radius
                              corners:(UIRectCorner)corners
                          borderWidth:(CGFloat)borderWidth
                          borderColor:(UIColor *)borderColor
                       borderLineJoin:(CGLineJoin)borderLineJoin{}

在實際開發中,需要注意:

  • 少量的離屏渲染不會帶來性能上的影響,不用爲了優化容不下一點離屏渲染。
  • 重要需要優化的應當放在UITableViewUICollectionView這種長列表的中。
  • 如果有大量的網絡圖片需要加載,這個時候添加圓角使用第一種方式更爲便捷。


二、穩定性優化

App Crash的常見類型主要包括以下幾種:

  • unrecognized selector crash(沒找到方法的實現)
  • KVO crash
  • NSTimer crash
  • Container crash/NSString(數組越界,插nil等)
  • Bad Access crash (野指針)
  • NSNotification crash
1.unrecognized selector crash沒有找到方法的實現

在解決這個問題的,我們先了解一下方法調用的流程:

  • 1.快速查找流程:OC方法的調用底層通過objc_msgSend(obj, sel)實現的(源碼彙編編寫),從實例的class->cache->_bucketsAndMaybeMask中查找方法的實現。如果沒有找到,則進入2.
  • 2.慢速查找流程:從當前類到父類的方法列表中一次查找調用的方法。如果沒有找到,則進入3.
  • 3.消息轉發流程:
  • 3.1.動態決議:+ (BOOL)resolveInstanceMethod:(SEL)sel/+(BOOL)resolveClassMethod:(SEL)sel,可以動態向類添加他自身不存在的方法實現。
  • 3.2.快速轉發:- (id)forwardingTargetForSelector:(SEL)aSelector, 動態指定一個可以執行該方法的實例。
  • 3.3.慢速轉發:- (void)forwardInvocation:(NSInvocation *)anInvocation,可以實現一個空函數。也可以重新指定消息的targetselector觸發消息。
  • 4.報錯:doesNotRecognizeSelector
    從上面的流程中,我們可以看到在消息轉發流程中,我們可以有三次機會去補救。但是每個方法各有側重,第一個方法會向類添加一些冗餘的方法;第三個需要創建方法的簽名和NSInvocation有一定的開銷,最合適的是第二個方法。

推薦一種較爲優雅的做法:我們可以創建一個"傀儡"類,動態爲該類添加無法執行的Selector方法,然後用一個通用的方法作爲該Selector的實現,將消息轉發到該傀儡類的實例上。


2.KVO crash

KVO導致的Crash主要原因有兩方面:

  • KVO的被觀察者dealloc時仍然註冊着KVO導致Crash。
  • 重複添加KVO觀察者或者重複移除觀察者。

那麼解決這個問題,就是保證KVO的觀察者dealloc的時候,移除觀察者,並且保證不重複添加移除觀察者。所以內部維護一個觀察者的關係映射是十分有必要的。
這裏可以參考我的開源框架NXKitNXKVOObserver類,它的原理很簡單:

open class NXKVOObserver : NSObject {
    //弱引用觀察者
    public fileprivate(set) weak var observer : NSObject? = nil
    //內部維護一個被觀察信息的列表[NXKVOObserver.Observation]
    public fileprivate(set) var observations = [NXKVOObserver.Observation]()
    
    //被觀察者的信息實體
    open class Observation : NSObject {
        //弱引用被觀察者
        weak open var object : NSObject? = nil
        open var key = ""
        open var options: NSKeyValueObservingOptions = []
        open var context: UnsafeMutableRawPointer? = nil
        open var completion : NX.Completion<String, [NSKeyValueChangeKey : Any]?>? = nil
        
        public init(object:NSObject, key:String, options:NSKeyValueObservingOptions, context:UnsafeMutableRawPointer?, completion:NX.Completion<String, [NSKeyValueChangeKey : Any]?>?) {
            self.object = object
            self.key = key
            self.options = options
            self.context = context
            self.completion = completion
        }
    }
    
    //初始化
    public init(observer:NSObject) {
        self.observer = observer
    }
    
    //添加觀察者和觀察者的屬性:判斷重複?!只有不重複的纔會真正添加
    open func add(object: NSObject, key: String, options: NSKeyValueObservingOptions = [], context: UnsafeMutableRawPointer? = nil, completion:NX.Completion<String, [NSKeyValueChangeKey : Any]?>? = nil){
        if self.observations.contains(where: { kvo in return kvo.object == object && kvo.key == key}) {
            
        }
        else {
            let observation = NXKVOObserver.Observation(object: object, key: key, options:options, context: context, completion: completion)
            self.observations.append(observation)
            object.addObserver(self, forKeyPath: key, options: options, context: context)
        }
    }
    
    //移除觀察者
    open func remove(object: NSObject, key: String) {
        if let index = self.observations.firstIndex(where: { kvo in return kvo.object == object && kvo.key == key}){
            self.observations.remove(at: index)
            object.removeObserver(self, forKeyPath: key)
        }
    }
    
    //移除所有觀察者
    open func removeAll() {
        for observation in self.observations {
            observation.object?.removeObserver(self, forKeyPath: observation.key)
        }
        self.observations.removeAll()
    }
    
    //攔截回調:可以通過閉包回調或者通過observeValue(...)方法回調
    open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        if let __object = object as? NSObject, let observation = self.observations.first(where: { kvo in return kvo.object == __object && kvo.key == keyPath}){
            if observation.completion != nil {
                observation.completion?(observation.key, change)
            }
            else if let __observer = self.observer,__observer.responds(to: #selector(NSObject.observeValue(forKeyPath:of:change:context:))) == true {
                __observer.observeValue(forKeyPath: observation.key, of: observation.object, change: change, context: context)
            }
        }
    }
    
    deinit {
        NX.print(NSStringFromClass(self.classForCoder))
    }
}

如何使用?以WebViewController觀察WebView爲例:

open class NXWebViewController: NXViewController {
    ...
    open var webView = NXWebView(frame: CGRect.zero)
    //初始化
    lazy var observer : NXKVOObserver = {
        return NXKVOObserver(observer: self)
    }()

    override open func viewDidLoad() {
       super.viewDidLoad()
       ....
       //添加被觀察者和屬性
       self.observer.add(object:self.webView, key: "title", options: [.new, .old], context: nil, completion: nil)
       self.observer.add(object:self.webView, key: "estimatedProgress", options: [.new, .old], context: nil, completion: nil)
    }

    override open func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        
    }
    
    deinit {
        self.webView.stopLoading()
        //移除全部的被觀察者
        self.observer.removeAll()
    }
}

整個結構非常輕巧,特別注意這裏邊的觀察者observer和被觀察者object都採用了弱引用,不會有循環引用的問題,那麼在觀察者dealloc中調用一下removeAll()即可;並且內部維護了觀察者信息的列表,所有的添加、移除、回調都會查找這個列表的數據,所以不存在重複添加移除的問題了。

如果覺得在多線程中操作不安全,可以在add(...)remove(...)removeAll(...)observeValue(...)位置添加一把鎖。


3.NSTimer crash

我們一般使用[NSTimer scheduledTimerWithTimeInterval:target:selector:userInfo:repeats: ]做重複性的定時任務,但是這個API會強引用target實例,默認形成循環引用。爲此我們需要在合適的時機invalidate定時器,斷開引用環,否則就會因爲循環引用雙發都無法釋放,導致內存泄露,甚至無限重複調用會導致資源的浪費。
解決這個問題的關鍵就是在合適的時機斷開引用環,這裏推薦如下方案:

  • 推薦方案-YYKit開源框架中的NSTimer (YYAdd)類提供的方案:它的本質是將強引用的對應設置爲自己,不與NSTimer的持有者構成循環引用,從而斷開循環引用。
  • 推薦方案-YYKit開源框架中的YYTimer類提供的方案:如果對定時器的精度要求很高,比如不受手指滑動屏幕的影響等建議採用,內部使用dispatch_source_timer實現,並且將target設置爲弱引用。
  • 可以根據實際的需要在viewWillDisappear(:)/viewDidDisappear(:)設置定時器失效-打開新的頁面或者頁面返回的時候都會調用。或在didMoveToParentViewController(:)設置定時器的實效-該方法會調用2次,頁面載入完成(viewDidAppear)之後會調用一次parent不爲空,頁面返回(viewDidDisappear)之後會調用一次parent爲空,打開新的頁面(viewDidDisappear)之後不會調用。


4.Container/NSString crash(數組越界,插nil等)

Container Crash是指NSArray/NSMutableArray/NSDictionary/NSMutableDictionary/NSCache等類的越界訪問或者插入nil等錯誤操作造成的。
解決方案:可以swizzle對應的插值和訪問方法,在swizzle的方法中做好空值和下標越界的判斷即可,也可以自己定義一套C API可以更簡潔的插值和讀取,同時內部做好空值和越界的判斷。兩種方式各有利弊,前者有一定的侵入性,後者攔截不是特別徹底。
NSString和NSMutableString的崩潰閃退問題,通常是越界操作引起的,處理方式同上。


5.Bad Access crash (野指針))

野指針造成的crash是我們開發中佔比較高的一個問題,Xcode提供了檢測殭屍對象Zombie的機制,能夠在發生野指針的時候提示出現野指針的類,從而解決開發階段出現的野指針問題,對於線上發生的野指針問題依舊不好排查。


6.NSNotification crash

這個問題主要是由於在NSNotificationCenter添加一個對象爲observer之後,如果在observer dealloc的時候,沒有調用[[NSNotificationCenter defaultCenter] removeObserver:self]會導致崩潰。這個問題出現在iOS 9.0之前,高版本蘋果對此做了優化,不會再有這個問題了。這個推薦在控制器等基類的dealloc方法中添加[[NSNotificationCenter defaultCenter] removeObserver:self]的調用即可。網上也有一些說法說是hook add方法,hook dealloc方法,這些都是方法,個人感覺太重了~~。


三、異常的收集

在應用啓動之後會對objc運行時異常回調進行初始化,異常回調用到_objc_terminate函數:

static void _objc_terminate(void){
    if (! __cxa_current_exception_type()) {
        // No current exception.
        (*old_terminate)();
    }
    else {
        // There is a current exception. Check if it's an objc exception.
        @try {
            __cxa_rethrow();
        } @catch (id e) {
            // It's an objc object. Call Foundation's handler, if any.
            (*uncaught_handler)((id)e);
            (*old_terminate)();
        } @catch (...) {
            // It's not an objc object. Continue to C++ terminate.
            (*old_terminate)();
        }
    }
}

如果捕獲到objc異常,回調用uncaught_handler(e),並將異常信息傳遞回去。uncaught_handler有個默認值是_objc_default_uncaught_exception_handler,該函數是空實現。在該文件中可以找到另外一個地方給uncaught_handler賦值:

/***********************************************************************
* objc_setUncaughtExceptionHandler
* Set a handler for uncaught Objective-C exceptions. 
* Returns the previous handler. 
**********************************************************************/
objc_uncaught_exception_handler objc_setUncaughtExceptionHandler(objc_uncaught_exception_handler fn){
    objc_uncaught_exception_handler result = uncaught_handler;
    uncaught_handler = fn;
    return result;
}

在Foundation層有一個

typedef void NSUncaughtExceptionHandler(NSException *exception);
FOUNDATION_EXPORT void NSSetUncaughtExceptionHandler(NSUncaughtExceptionHandler * _Nullable);

我們可以在應用啓動完成後調用該函數,然後捕獲異常信息,並將該信息先保存到本地,等下一次應用啓動的時候再將該信息通過接口提交給服務器。

@implementation EXExceptionHandler
+ (instancetype)center {
    static dispatch_once_t t;
    static EXExceptionHandler *center = nil;
    dispatch_once(&t, ^{
        center = [[self alloc] init];
    });
    return center;
}

- (void)start{
    NSSetUncaughtExceptionHandler(&ExceptionHandler);
}

void ExceptionHandler(NSException *exception) {    
    NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:exception.userInfo];
    [userInfo setObject:exception.name forKey:EXExceptionHandlerExceptionName];
    [userInfo setObject:exception.reason forKey:EXExceptionHandlerExceptionReason];
    [userInfo setObject:exception.callStackSymbols forKey:EXExceptionHandlerExceptionCallStackSymbols];
    [userInfo setObject:@"EXException" forKey:EXExceptionHandlerExceptionFileKey];
    NSException *e = [[NSException alloc] initWithName:exception.name reason:exception.reason userInfo:userInfo];
    [EXExceptionHandler.center handleException:e];
}

- (void)handleException:(NSException *)exception{
    NSLog(@"將異常信息/設備信息/時間信息保存到本地;合適時提交到服務器:%@", exception.userInfo);
}
@end
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章