讀書筆記(一)
一、instancetype 和 id 作爲初始化實例 返回值的不同
Objective-C的一些使用慣例不僅僅是好的編程習慣,更是給編譯器的隱藏指令。
例如, alloc 和 init 的返回類型都是 id ,然而在Xcode中,編譯器會檢查所有正確類型。它是怎麼做到的呢?
在Cocoa中,約定 alloc 或 init 的方法總是返回接收器類實例的對象。據說這些方法有一個相關返回類型。
雖然類構造方法也是返回 id ,但是類構造方法並沒有做同樣的類型檢查,因爲它們不遵循命名規範。
你可以自己試着這樣:
[[[NSArray alloc] init] mediaPlaybackAllowsAirPlay]; // ❗ "No visible @interface for `NSArray` declares the selector `mediaPlaybackAllowsAirPlay`"
[[NSArray array] mediaPlaybackAllowsAirPlay]; // (No error)
由於alloc 和 init 作爲相關返回類型遵循命名規範,執行對NSArray的正確類型檢查。然而,等價類構造函數array不遵循命名規範,它被認爲是id類型。
id類型對禁用類型安全性檢查非常有用,但當你確實需要它的時候卻沒有時,情況會變得非常糟糕。
另一種顯示聲明返回類型(在之前例子中的 (NSArray *))的方式有了稍微的改進,但是它不利於子類的發揮。
所以編譯器從這裏介入以解決Objective-C類型系統的這個永恆邊界情況:
instancetype 關鍵字,它可以表示一個方法的相關返回類型。例如:
@interface Person
+(instancetype)personWithName:(NSString *)name;
@end
- instancetype 與 id 不一樣, instancetype 只能在方法聲明中作爲返回類型使用。
使用 instancetype ,編譯器將正確的推斷出 +personWithName: 是 Person 的一個實例。
爲了在不久的將來使用 instancetype ,你可以在Foundation中查找類構造函數。例如UICollectionViewLayoutAttributes 就已經正在使用 instancetype 了。
二、iOS爲什麼不要在init初始化方法裏調用self.view
首先.如果你調用self.view的時候,就會調用view的getter方法, 這個時候,view是空的,那麼系統就會自動給你創建一個view,然後就會觸發ViewDidLoad方法.那麼這個時候,如果你init方法裏有數組初始化.但是你還沒走到那步,而直接就給數組賦值了,那麼這個值賦值給了一個不存在的數組.這樣就容易出現錯誤.所以,儘量不要在init方法裏寫可視化控件的語句.
三、忽略編譯警告
如果你知道你的代碼不會導致內存泄露,你可以通過加入這些代碼忽略這些警告
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[myObj performSelector:mySelector withObject:name];
#pragma clang diagnostic pop
注意我們是如何在相關代碼上下文中用 pragma 停用 -Warc-performSelector-leaks 檢查的。這確保我們沒有全局禁用。如果全局禁用,可能會導致錯誤。
使用 #pragma unused():忽略沒用使用變量的編譯警告
-(NSInteger)giveMeFive
{
NSString *foo;
#pragma unused (foo)
return 5;
}
四、Block的深入學習
一些關鍵點:
block 是在棧上創建的
block 可以複製到堆上
Block會捕獲棧上的變量(或指針),將其複製爲自己私有的const(變量)。
(如果在Block中修改Block塊外的)棧上的變量和指針,那麼這些變量和指針必須用__block關鍵字申明(譯者注:否則就會跟上面的情況一樣只是捕獲他們的瞬時值)。
block 可以聲明成全局靜態的
如果 block 沒有在其他地方被保持,那麼它會隨着棧生存並且當棧幀(stack frame)返回的時候消失。僅存在於棧上時,block對對象訪問的內存管理和生命週期沒有任何影響。
如果 block 需要在棧幀返回的時候存在,它們需要明確地被複制到堆上,這樣,block 會像其他 Cocoa 對象一樣增加引用計數。當它們被複制的時候,它會帶着它們的捕獲作用域一起,retain 他們所有引用的對象。
如果一個 block引用了一個棧變量或指針,那麼這個block初始化的時候會擁有這個變量或指針的const副本,所以(被捕獲之後再在棧中改變這個變量或指針的值)是不起作用的。(譯者注:所以這時候我們在block中對這種變量進行賦值會編譯報錯:Variable is not assignable(missing __block type specifier),因爲他們是副本而且是const的.具體見下面的例程)。
當一個 block 被複制後,__block 聲明的棧變量的引用被複制到了堆裏,複製完成之後,無論是棧上的block還是剛剛產生在堆上的block(棧上block的副本)都會引用該變量在堆上的副本。
(下面代碼是譯者加的)
...
CGFloat blockInt = 10;
void (^playblock)(void) = ^{
NSLog(@"blockInt = %zd", blockInt);
};
blockInt ++;
playblock();
...
//結果爲:blockInt = 10
最重要的事情是 __block 聲明的變量和指針在 block 裏面是作爲顯示操作真實值/對象的結構來對待的。
block 在 Objective-C 的 runtime(運行時) 裏面被當作一等公民對待:他們有一個 isa 指針,一個類也是用 isa 指針在Objective-C 運行時來訪問方法和存儲數據的。在非 ARC 環境肯定會把它搞得很糟糕,並且懸掛指針會導致 crash。__block 僅僅對 block 內的變量起作用,它只是簡單地告訴 block:
嗨,這個指針或者原始的類型依賴它們在的棧。請用一個棧上的新變量來引用它。我是說,請對它進行雙重解引用,不要 retain 它。 謝謝,哥們。
如果在定義之後但是 block 沒有被調用前,對象被釋放了,那麼 block 的執行會導致 crash。 __block 變量不會在 block 中被持有,最後… 指針、引用、解引用以及引用計數變得一團糟。
self 的循環引用
當使用代碼塊和異步分發的時候,要注意避免引用循環。 總是使用 weak 來引用對象,避免引用循環。(譯者注:這裏更爲優雅的方式是採用影子變量@weakify/@strongify 這裏有更爲詳細的說明) 此外,把持有 block 的屬性設置爲 nil (比如 self.completionBlock = nil) 是一個好的實踐。它會打破 block 捕獲的作用域帶來的引用循環。
例子:
__weak __typeof(self) weakSelf = self;
[self executeBlock:^(NSData *data, NSError *error) {
[weakSelf doSomethingWithData:data];
}];
不要這樣:
[self executeBlock:^(NSData *data, NSError *error) {
[self doSomethingWithData:data];
}];
多個語句的例子:
__weak __typeof(self)weakSelf = self;
[self executeBlock:^(NSData *data, NSError *error) {
__strong __typeof(weakSelf) strongSelf = weakSelf;
if (strongSelf) {
[strongSelf doSomethingWithData:data];
[strongSelf doSomethingWithData:data];
}
}];
不要這樣:
__weak __typeof(self)weakSelf = self;
[self executeBlock:^(NSData *data, NSError *error) {
[weakSelf doSomethingWithData:data];
[weakSelf doSomethingWithData:data];
}];
你應該把這兩行代碼作爲 snippet 加到 Xcode 裏面並且總是這樣使用它們。
__weak __typeof(self)weakSelf = self;
__strong __typeof(weakSelf)strongSelf = weakSelf;
這裏我們來討論下 block 裏面的 self 的 weak 和 strong 限定詞的一些微妙的地方。簡而言之,我們可以參考 self 在 block 裏面的三種不同情況。
直接在 block 裏面使用關鍵詞 self
在 block 外定義一個 __weak 的 引用到 self,並且在 block 裏面使用這個弱引用
在 block 外定義一個 __weak 的 引用到 self,並在在 block 內部通過這個弱引用定義一個 __strong 的引用。
方案 1. 直接在 block 裏面使用關鍵詞 self
如果我們直接在 block 裏面用 self 關鍵字,對象會在 block 的定義時候被 retain,(實際上 block 是 copied 但是爲了簡單我們可以忽略這個)。一個 const 的對 self 的引用在 block 裏面有自己的位置並且它會影響對象的引用計數。如果這個block被其他的類使用並且(或者)彼此間傳來傳去,我們可能想要在 block 中保留 self,就像其他在 block 中使用的對象一樣. 因爲他們是block執行所需要的.
dispatch_block_t completionBlock = ^{
NSLog(@"%@", self);
}
MyViewController *myController = [[MyViewController alloc] init...];
[self presentViewController:myController
animated:YES
completion:completionHandler];
沒啥大不了。但是如果通過一個屬性中的 self 保留 了這個 block(就像下面的例程一樣),對象( self )保留了 block 會怎麼樣呢?
self.completionHandler = ^{
NSLog(@"%@", self);
}
MyViewController *myController = [[MyViewController alloc] init...];
[self presentViewController:myController
animated:YES
completion:self.completionHandler];
這就是有名的 retain cycle, 並且我們通常應該避免它。這種情況下我們收到 CLANG 的警告:
Capturing 'self' strongly in this block is likely to lead to a retain cycle (在 block 裏面發現了 `self` 的強引用,可能會導致循環引用)
所以 __weak 就有用武之地了。
方案 2. 在 block 外定義一個 __weak 的 引用到 self,並且在 block 裏面使用這個弱引用
這樣會避免循壞引用,也是通常情況下我們的block作爲類的屬性被self retain 的時候會做的。
__weak typeof(self) weakSelf = self;
self.completionHandler = ^{
NSLog(@"%@", weakSelf);
};
MyViewController *myController = [[MyViewController alloc] init...];
[self presentViewController:myController
animated:YES
completion:self.completionHandler];
這個情況下 block 沒有 retain 對象並且對象在屬性裏面 retain 了 block 。所以這樣我們能保證了安全的訪問 self。 不過糟糕的是,它可能被設置成 nil 的。問題是:如何讓 self 在 block 裏面安全地被銷燬。
考慮這麼個情況:block 作爲屬性(property)賦值的結果,從一個對象被複制到另一個對象(如 myController),在這個複製的 block 執行之前,前者(即之前的那個對象)已經被解除分配。
下面的更有意思。
方案 3. 在 block 外定義一個 weak 的 引用到 self,並在在 block 內部通過這個弱引用定義一個 strong 的引用
你可能會想,首先,這是避免 retain cycle 警告的一個技巧。
這不是重點,這個 self 的強引用是在block 執行時 被創建的,但是否使用 self 在 block 定義時就已經定下來了, 因此self (在block執行時) 會被 retain.
Apple 文檔 中表示 “爲了 non-trivial cycles ,你應該這樣” :
MyViewController *myController = [[MyViewController alloc] init...];
// ...
MyViewController * __weak weakMyController = myController;
myController.completionHandler = ^(NSInteger result) {
MyViewController *strongMyController = weakMyController;
if (strongMyController) {
// ...
[strongMyController dismissViewControllerAnimated:YES completion:nil];
// ...
}
else {
// Probably nothing...
}
};
首先,我覺得這個例子看起來是錯誤的。如果 block 本身在 completionHandler 屬性中被 retain 了,那麼 self 如何被 delloc 和在 block 之外賦值爲 nil 呢? completionHandler 屬性可以被聲明爲 assign 或者 unsafe_unretained 的,來允許對象在 block 被傳遞之後被銷燬。
我不能理解這樣做的理由,如果其他對象需要這個對象(self),block 被傳遞的時候應該 retain 對象,所以 block 應該不被作爲屬性存儲。這種情況下不應該用weak/strong
總之,其他情況下,希望 weakSelf 變成 nil 的話,就像第二種情況解釋那麼寫(在 block 之外定義一個弱應用並且在 block 裏面使用)。
還有,Apple的 “trivial block” 是什麼呢。我們的理解是 trivial block 是一個不被傳送的 block ,它在一個良好定義和控制的作用域裏面,weak 修飾只是爲了避免循環引用。
雖然有 Kazuki Sakamoto 和 Tomohiko Furumoto) 討論的 一 些 的 在線 參考, Matt Galloway 的 (Effective Objective-C 2.0 和 Pro Multithreading and Memory Management for iOS and OS X ,大多數開發者始終沒有弄清楚概念。
在 block 內用強引用的優點是,搶佔執行的時候的魯棒性。在 block 執行的時候, 再次溫故下上面的三個例子:
方案 1. 直接在 block 裏面使用關鍵詞 self
如果 block 被屬性 retain,self 和 block 之間會有一個循環引用並且它們不會再被釋放。如果 block 被傳送並且被其他的對象 copy 了,self 在每一個 copy 裏面被 retain
方案 2. 在 block 外定義一個 __weak 的 引用到 self,並且在 block 裏面使用這個弱引用
不管 block 是否通過屬性被 retain ,這裏都不會發生循環引用。如果 block 被傳遞或者 copy 了,在執行的時候,weakSelf 可能已經變成 nil。
block 的執行可以搶佔,而且對 weakSelf 指針的調用時序不同可以導致不同的結果(如:在一個特定的時序下 weakSelf 可能會變成nil)。
__weak typeof(self) weakSelf = self;
dispatch_block_t block = ^{
[weakSelf doSomething]; // weakSelf != nil
// preemption, weakSelf turned nil
[weakSelf doSomethingElse]; // weakSelf == nil
};
方案 3. 在 block 外定義一個 weak 的 引用到 self,並在在 block 內部通過這個弱引用定義一個 strong 的引用。
不管 block 是否通過屬性被 retain ,這裏也不會發生循環引用。如果 block 被傳遞到其他對象並且被複制了,執行的時候,weakSelf 可能被nil,因爲強引用被賦值並且不會變成nil的時候,我們確保對象 在 block 調用的完整週期裏面被 retain了,如果搶佔發生了,隨後的對 strongSelf 的執行會繼續並且會產生一樣的值。如果 strongSelf 的執行到 nil,那麼在 block 不能正確執行前已經返回了。
__weak typeof(self) weakSelf = self;
myObj.myBlock = ^{
__strong typeof(self) strongSelf = weakSelf;
if (strongSelf) {
[strongSelf doSomething]; // strongSelf != nil
// preemption, strongSelf still not nil(搶佔的時候,strongSelf 還是非 nil 的)
[strongSelf doSomethingElse]; // strongSelf != nil
}
else {
// Probably nothing...
return;
}
};
在ARC條件中,如果嘗試用 -> 符號訪問一個實例變量,編譯器會給出非常清晰的錯誤信息:
Dereferencing a weak pointer is not allowed due to possible null value caused by race condition, assign it to a strong variable first. (對一個weak 指針的解引用不允許的,因爲可能在競態條件裏面變成 null, 所以先把他定義成 strong 的屬性)
可以用下面的代碼展示
__weak typeof(self) weakSelf = self;
myObj.myBlock = ^{
id localVal = weakSelf->someIVar;
};
在最後
方案 1: 只能在 block 不是作爲一個 property 的時候使用,否則會導致 retain cycle。
方案 2: 當 block 被聲明爲一個 property 的時候使用。
方案 3: 和併發執行有關。當涉及異步的服務的時候,block 可以在之後被執行,並且不會發生關於 self 是否存在的問題。
五、IOS高效添加圓角效果
誤區一:
view.layer.cornerRadius = 5
該代碼經過測試是不會造成內存性能損耗。
view.layer.masksToBounds = Yes // 遮罩layer層以下的視
圖,使圓角生效
這代碼纔是造成性能損耗的關鍵因素。由於該代碼會導致視圖離屏渲染(Color Offscreen-Renderd Yellow)相關文章:UIKit性能調優實戰講解
注意事項:我們應該儘量避免重寫 drawRect 方法,用CAShapeLayer代替圖層繪製。不恰當的使用這個方法
會導致內存暴增。舉個例子,iPhone6 上與屏幕等大的 UIView,即使重寫一個空的 drawRect 方法,它也
至少佔用 750 1134 4 字節 ≈ 3.4 Mb 的內存。在內存惡鬼drawRect 及其後續中,作者詳細介紹了
其中原理,據他測試,在 iPhone6 上空的、與屏幕等大的視圖重寫 drawRect 方法會消耗 5.2 Mb 內存。
總之,能避免重寫 drawRect 方法就儘可能避免。
其次,這種方法本質上是用遮罩層 mask 來實現,因此同樣無可避免的會導致離屏渲染。我試着將此前 34 個
視圖的圓角改用這種方法實現,結果 fps 掉到 11 左右。已經屬於卡出翔的節奏了。
高效設置圓角實戰:
爲 UIView 添加圓角
這種做法的原理是手動畫出圓角。雖然我們之前說過,爲普通的視圖直接設置 cornerRadius 屬性即可。但萬一不可避免的需要使用 masksToBounds,就可以使用下面這種方法,它的核心代碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
func kt_drawRectWithRoundedCorner(radius radius: CGFloat, borderWidth: CGFloat, backgroundColor: UIColor, borderColor: UIColor) -> UIImage { UIGraphicsBeginImageContextWithOptions(sizeToFit, false, UIScreen.mainScreen().scale) let context = UIGraphicsGetCurrentContext() CGContextMoveToPoint(context, 開始位置); // 開始座標右邊開始 CGContextAddArcToPoint(context, x1, y1, x2, y2, radius); // 這種類型的代碼重複四次 CGContextDrawPath(UIGraphicsGetCurrentContext(), .FillStroke) let output = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return output } |
這個方法返回的是 UIImage,也就是說我們利用 Core Graphics 自己畫出了一個圓角矩形。除了一些必要的代碼外,最核心的就是 CGContextAddArcToPoint 函數。它中間的四個參數表示曲線的起點和終點座標,最後一個參數表示半徑。調用了四次函數後,就可以畫出圓角矩形。最後再從當前的繪圖上下文中獲取圖片並返回。
有了這個圖片後,我們創建一個 UIImageView 並插入到視圖層級的底部:
1 2 3 4 5 6 7 8 9 10 11 12 |
extension UIView { func kt_addCorner(radius radius: CGFloat, borderWidth: CGFloat, backgroundColor: UIColor, borderColor: UIColor) { let imageView = UIImageView(image: kt_drawRectWithRoundedCorner(radius: radius, borderWidth: borderWidth, backgroundColor: backgroundColor, borderColor: borderColor)) self.insertSubview(imageView, atIndex: 0) } } |
完整的代碼可以在項目中找到,使用時,你只需要這樣寫:
1 2 |
let view = UIView(frame: CGRectMake(1,2,3,4)) view.kt_addCorner(radius: 6) |
爲 UIImageView 添加圓角
相比於上面一種實現方法,爲 UIImageView 添加圓角更爲常用。它的實現思路是直接截取圖片:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
extension UIImage { func kt_drawRectWithRoundedCorner(radius radius: CGFloat, _ sizetoFit: CGSize) -> UIImage { let rect = CGRect(origin: CGPoint(x: 0, y: 0), size: sizetoFit) UIGraphicsBeginImageContextWithOptions(rect.size, false, UIScreen.mainScreen().scale) CGContextAddPath(UIGraphicsGetCurrentContext(), UIBezierPath(roundedRect: rect, byRoundingCorners: UIRectCorner.AllCorners, cornerRadii: CGSize(width: radius, height: radius)).CGPath) CGContextClip(UIGraphicsGetCurrentContext()) self.drawInRect(rect) CGContextDrawPath(UIGraphicsGetCurrentContext(), .FillStroke) let output = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return output } } ` |
圓角路徑直接用貝塞爾曲線繪製,一個意外的 bonus 是還可以選擇哪幾個角有圓角效果。這個函數的效果是將原來的 UIImage 剪裁出圓角。配合着這函數,我們可以爲 UIImageView 拓展一個設置圓角的方法:
1 2 3 4 5 6 7 8 9 |
extension UIImageView { /** / !!!只有當 imageView 不爲nil 時,調用此方法纔有效果 :param: radius 圓角半徑 */ override func kt_addCorner(radius radius: CGFloat) { self.image = self.image?.kt_drawRectWithRoundedCorner(radius: radius, self.bounds.size) } } |
完整的代碼可以在項目中找到,使用時,你只需要這樣寫:
1 2 |
let imageView = let imgView1 = UIImageView(image: UIImage(name: "")) imageView.kt_addCorner(radius: 6) |
六、UIKit 性能優化 讀後總結
原文地址:UIKit性能調優實戰講解
首選來說 爲什麼我們要進行UIKit優化?
直接上圖:
如圖所示,正常情況圖層的渲染是通過CPU的計算和GPU的處理完成 正常的渲染流程的。
- 當CPU和GPU的耗時時間控制在16.67ms之間(如第一部分)圖層就會正常渲染。
- 當CPU和GPU的耗時時間超過了16.67ms(如第二部分)圖層渲染就會掉幀,即屏幕就會出現花屏或者卡頓的現象。
所以,爲了保證圖層的正常渲染以及提高性能,我們着重從緩減CPU 和 GPU 不必要的消耗上,來做優化和調整。
1.爲什麼要把控件儘量設置成不透明,如果是透明的會有什麼影響,如何檢測這種影響?
- 影響:如果控件帶有透明度,這就會導致其控件與下層的控件的顏色 混合在一塊。例如,上層是藍色(透明度50%),下層是紅色,那麼最終顯示的是紫色。
這種顏色的混合,需要GPU去處理混合的結果,這就一定程度的造成GPU額外的消耗。當實際層次更加複雜的時候(三層,五層),GPU的消耗更加明顯,對性能的影響就很明顯了。如果將最上層的透明度設置爲1,這樣GPU就會忽略該層下面所有層次,節約了不必要的計算,從而提升性能。 - 檢測:檢測步驟,通過蘋果自帶的軟件instruments中的core animation 選項 -> 選擇color Blended Layers 選項 紅色區域就是透明度的設置.
- 優化措施:
- opaque = true 當然這個UIView默認就是 true的
- alpha = 1 透明度儘量設置爲1
- 最重要的是要設置背景顏色與父類一直,如果不設置默認會是透明的,另外clearColor(背景色透明)也不能設置,。label.backgroundColor = UIColor.WhiteColor()
4.離屏渲染
離屏渲染就是相對於正常的渲染流程(將圖層直接合成到幀的緩衝區中),增加了一個額外的步驟(先創建屏幕外緩衝區,然後渲染到文理中,最後將結果渲染到幀的緩衝區),這個步驟中需要GPU額外的處理計算,造成很大消耗,所以俗稱:離屏渲染。
我們先看看正常的渲染通道(Render-Pass):
正常的渲染是OpenGL 提交一個命令到 command buff ,隨後GPU 進行渲染 ,然後將渲染結果合成到(render buff)幀緩衝區中。
但是複雜的效果無法直接渲染出結果,需要分佈渲染最後組合起來,比如添加一個蒙版(mask):
如圖所示,渲染流程中,分爲好幾部分進行的。在前兩個渲染通道中,GPU分別得到紋理(texture,也就是相機圖標)和layer(藍色的蒙版)的渲染
結果。但這兩個渲染結果沒有直接放到render buff(幀緩衝區中),也就是發生了離屏渲染,知道第三個渲染通道,才把兩者結合起來放入 render buff (幀緩衝區中)。離屏渲染實際上就是將渲染結果臨時保存起來,等到用的時候再取出來,因此相對於普通渲染佔用資源。
以下情況可能會導致離屏渲染:
1 2 3 4 |
1.重寫drawRect 方法。(一般情況下畫圖都用CAShapeLayer) 2.有mask(圓角)或者陰影(layer.masksToBounds,layer.shadow),模糊效果也是一種mask (當使用圓角或者陰影是最好開啓光柵化以達到緩衝的 效果) 3.光柵化 layer.shouldRasterize = true |
3. 光柵化
所謂光柵化其實就是將一個layer預先渲染成位圖(bitmap),然後加入緩存中。如果對於陰影效果這樣的比較消耗資源的靜態內容進行緩存、
可以得到一定幅度的性能提升。
1
|
label.layer.shouldRasterize = true
|
緩衝中的對象有效期只有100ms(0。1s)如果超過這個時間緩存會自動清理。光柵化是把雙刃劍,先寫入緩存再讀取會消耗一定的時間,因此在沒有必要的情況下儘量不要使用。除非碰到很複雜、靜態的效果才能使用。會導致離屏渲染。
4.圖片大小
儘量保持圖片的大小合適,以及圖片的格式 GPU所支持。如果圖片偏大偏小,都需要GPU額外的去計算處理造成GPU的消耗,從而增加處理時間。
而圖片的格式如果GPU 不支持的話同樣需要GPU去做額外的轉化,消耗大量的時間。
本文標題:【iOS讀書筆記系列(一)】
文章作者:RunningYoung
發佈時間:2016-02-24, 14:28:01
最後更新:2016-05-12, 11:15:05
原始鏈接:https://runningyoung.github.io/2016/02/24/2016-03-12-read1/
許可協議: "署名-非商用-相同方式共享 4.0" 轉載請保留原文鏈接及作者。