Today Extension -數據共享與后臺下載

之前學習Today Extension的時候,被幾個問題困住無法解決,網上也沒有很好的解答。最近問了大師一些處理的思路,也查找了一些官方的文檔,就按照自己的思路將Today Extension的開發過程記錄下來。

1. 基礎概念
1.1 文檔結構
1.2 配置
1.3 NCWidgetProviding協議
2.數據共享
2.1 主流應用分析
2.2 后臺下載分析

1. 基礎

Today Widget出現在系統的兩個位置:

按壓應用圖標出現的彈框:
點擊應用圖標的Today Widget彈框

該彈框大小固定且沒有任何模式切換,較為簡單。

通知中心的Today 模塊中
通知中心的Today Widget

通知模塊中的Today Widget較為復雜,能進行模式切換以及大小的改變,當然在大小的切換中也能添加動畫的效果。

1.1 文檔結構

在Target中創建Today Extension之后,在工程中出現Extension的文檔結構:

Today Extension的文檔結構

主要分為TodayViewController、storyboard和Info.plist三個主要文件。類似項目初創時的結構但是仍有不同,具體原因是:

An app extension is different from an app. Although you must use an app 
to contain and deliver your extensions, each extension is a separate 
binary that runs independent of the app used to deliver it.
一個應用擴展是不同于一個應用的。盡管你必須使用一個應用來包括并且交付你的擴展,但是每一
個擴展是一個獨立的二進制獨立于交付的應用運行。

①Extension依賴于容器應用存在,由宿主應用觸發啟動,所以并沒有main函數的入口。
②每一個Extension都是一個獨立的二進制文件,所以存在Info.plist文件能進行獨立的配置。

在應用的bundle包中Extension的獨立文件
1.2 配置
Info.plist文件中鍵值主要是用戶易讀的模式,通過右擊選擇Show Raw Keys/values,轉換為官方文檔中標記模式。

進入Today Extension中的Info.plist配置文件中,有三點需要注意的部分:

① HTTP請求

如果在Today Extension中進行HTTP請求,需要對Extension中的Info.plist文件進行HTTP請求安全配置,不然系統會警告。


1.png
②更換Today Extension的顯示名稱
The displayed name of your app extension is provided by the extension 
target’s CFBundleDisplayName value, which you can edit in the 
extension’s Info.plist file. If you don’t provide a value for the 
CFBundleDisplayName key, your extension uses the name of its containing 
app, as it appears in the CFBundleName value.
由擴展目標中的CFBundleDisplayName值提供你應用擴展的顯示名稱,能在擴展的
Info.plist文件中進行編輯。如果你并沒有提供該值,你的擴展使用出現在CFBundleName值中
的容器的名稱。

官方文檔中的意思是能通過CFBundleDisplayName的值更改Extension的名稱,但是基本上目前大多數的Today Extension都是容器應用的名稱。

要求Today Extension顯示名稱必須和容器應用的名稱存在對應關系
③NSExtension
默認的NSExtension配置

默認的NSExtension只有兩項:

NSExtensionMainStoryboard :MainInterface

Extension默認使用storyboard故事板進行界面布局,如果想通過純代碼模式,將該行改為NSExtensionPrincipalClass 鍵和對應的主視圖控制器的名稱:


切換成純代碼布局模式
NSExtensionPointIdentifier鍵是擴展點反轉的DNS名

該鍵是系統必須的配置,并且值不變(不需要改變)。

1.3 NCWidgetProviding協議

NCWidgetProviding是對一個自定義內容的可選協議,因為系統對Extension在內存、顯示方面有很嚴格的限制,所以該協議實現只包括以下的三個方法:

//系統將會在合適的機會為小部件更新它的狀態,當通知中心可視化和它在后臺時。
//相反的,應該從viewWillAppear中加載緩存的狀態
- (void)widgetPerformUpdateWithCompletionHandler:(void (^)(NCUpdateResult result))completionHandler;

//當激活的顯示狀態模式改變的時候調用,小部件可能希望改變它的preferredContentSize更好的適應新的顯示模式。
//需要注意,固定兩種模式下小部件的寬度為設備的寬度,所以傳任何值都不會有任何影響,一般直接寫0即可。
- (void)widgetActiveDisplayModeDidChange:(NCWidgetDisplayMode)activeDisplayMode withMaximumSize:(CGSize)maxSize NS_AVAILABLE_IOS(10_0);

//自定義默認的邊緣間隙,但是已經在iOS 10.0版本中被拋棄
- (UIEdgeInsets)widgetMarginInsetsForProposedMarginInsets:(UIEdgeInsets)defaultMarginInsets NS_DEPRECATED_IOS(8_0, 10_0, "This method will not be called on widgets linked against iOS versions 10.0 and later.");

在Today Widget中,默認是緊湊的模式,必須將最大顯示模式修改成NCWidgetDisplayModeExpanded模式:

//窗口小部件能改變他們能改變的最大顯示模式
@property (nonatomic, assign) NCWidgetDisplayMode widgetLargestAvailableDisplayMode NS_AVAILABLE_IOS(10_0);

self.extensionContext.widgetLargestAvailableDisplayMode = NCWidgetDisplayModeExpanded;

同時根據協議中的方法不斷監聽該模式的切換,并且系統并不會在切換模式的時候計算小部件的大小,所以還是需要手動的設置preferredContentSize的大小:

-(void)widgetActiveDisplayModeDidChange:(NCWidgetDisplayMode)activeDisplayMode withMaximumSize:(CGSize)maxSize
{
    if (activeDisplayMode == NCWidgetDisplayModeCompact) {
         
           self.preferredContentSize = CGSizeMake(0, 110);
    } else if (activeDisplayMode == NCWidgetDisplayModeExpanded) {
            self.preferredContentSize = CGSizeMake(0, 250);
    }
}
2.數據共享
2.1 主流應用分析

Extension應用和容器應用間不能直接進行任何的交互,包括數據。所以在數據方面能使用官方文檔中最詳細的推薦就是共享的userDefaults的使用。
以當前的一些常用應用為例子分析數據方面的使用情況:

①靜態布局

如支付寶和大麥等應用,Today Extension中只有一些靜態按鈕構成的簡單界面,甚至沒有擴展的模式的存在。


簡單布局

其實Today Widget的目的是為了以最簡單的方式展示最新的信息,這種方式更多是3D Touch提供快捷入口的方式。但是從Widget性能的嚴格要求等方面,也是一種保險,不會出錯的方式。

②與容器應用間簡單的數據交互

如京東等應用,除去靜態的布局外,中間部分是需要和容器應用保持一樣的倒計時功能。


京東Today Widget界面
倒計時功能實現
如果在宿主應用和Extension中存在相同的代碼,可以自定義Framework。

在宿主應用中開啟倒計時功能,當監聽到應用即將進入后臺的通知時,將倒計時關閉并且將當前的值存入共享的UserDefaults中;當監聽到應用進入前臺的通知時,將倒計時開啟并且從共享NSUserDefaults中獲得最新的倒計時數。

- (void)viewDidLoad {
    [super viewDidLoad];
    _index = 100;
    self.timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(countDownTime) userInfo:nil repeats:YES];
    [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
    //應用即將進入后臺的通知監聽
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appResignActive) name:UIApplicationWillResignActiveNotification object:nil];
//應用已經被激活,進入前臺的通知監聽
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appBecomeActive) name:UIApplicationDidBecomeActiveNotification object:nil];
}
//暫停當前的時間
- (void)stop {
    [self.timer setFireDate:[NSDate distantFuture]];
    //將時間保存在共享的userDafaults中
    NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.session.data"];
    [userDefaults setInteger:_index forKey:@"countDown"];
    [userDefaults synchronize];
}
- (void)resume {  
    NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.session.data"];
    _index = [userDefaults integerForKey:@"countDown"];
    [self.timer setFireDate:[NSDate date]];
}
- (void)countDownTime{
    _index --;
    if (_index == 0) {
        [self.timer invalidate];
        self.timer = nil;
    }  
   self.countLabel.text = [NSString stringWithFormat:@"倒計時:%lds",_index];
}
- (void)appResignActive{
    [self stop];
}
- (void)appBecomeActive{
    [self resume];
}

在Today Extension中也需要開啟倒計時的功能,當倒計時為0時,自動打開容器應用。并且當倒計時值不為0的情況下離開,推薦在viewWillDisappear:方法中保存最新的數據狀態。

//在viewWillAppear中加載緩存的數據
-(void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.session.data"];
    NSInteger index = [userDefaults integerForKey:@"countDown"];
    
    _index = index;
    self.timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(countDown) userInfo:nil repeats:YES];
    [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
}
- (void)countDown{
    _index --;
    if (_index == 0) {
//在index ==0的情況下通過openURL打開宿主應用
        [self.extensionContext openURL:[NSURL URLWithString:@"lizhou://TimerIsOut"] completionHandler:^(BOOL success) {
           
            if (success) {
                NSLog(@"success");
            } else {
                NSLog(@"failure");
            }
        }];
    }
    self.normalTitle.text = [NSString stringWithFormat:@"倒計時:%@s",self.timer];
}
-(void)viewWillDisappear:(BOOL)animated
{
    [super viewWillDisappear:animated];
    
    if (_index ! = 0) {
        NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.session.data"];
        
        [userDefaults setInteger:_index forKey:@"countDown"];
        [userDefaults synchronize];
    }
    
}

可以參考關于2014 Extension方面的筆記:
WWDC 2014 Session筆記 - iOS 通知中心擴展制作入門

③網絡數據交互

如愛奇藝、優酷等一些視頻應用中,對于新數據存在一定的要求。

愛奇藝的Today Extension

愛奇藝的Today Extension的擴展中,歷史記錄是直接從共享的userDefaults中獲取的,但是對于圖片資源而言,有兩種方法:

#######以圖片的URL進行存儲
以URL進行存儲時緩存占據較少,并且能及時的清理。但是如果在網絡不穩定的情況下,第一次開啟時會導致圖片無法顯示,所以還是需要本地的預存數據占位。
所以首先在viewWillApper中對共享數據中的圖片URL進行獲取,并且只有在數據獲取成功的情況下開啟倒計時:

-(void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    
    _index = 0;
    NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.session.data"];
    NSDictionary *historyDic = [userDefaults dictionaryForKey:@"history"];
    self.normalTitle.text = [historyDic objectForKey:@"title"];
    self.normalOfLastProgressLabel.text = [NSString stringWithFormat:@"剩余%@%%,繼續看",[historyDic objectForKey:@"content"]];

    NSArray *imageArrs = [userDefaults objectForKey:@"images"];
    self.imagesArr = [imageArrs mutableCopy];

    if (self.imagesArr.count > 0) {
        self.timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(changeImage) userInfo:nil repeats:YES];
        [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
    }
}

在倒計時的方法中:當然是在數據加載成功之后停留5秒鐘,并且需要考慮到下載過程中切換到后臺的處理,這方面直接使用SDWebImage即可:

-(void)stopTimer{
    [self.timer setFireDate:[NSDate distantFuture]];
}
//圖片展示提留5秒。如果設置為當前時間立刻執行,那么也不會考慮NSTimer的間隔時間,馬上切換。
- (void)beginTimer{
    [self.timer setFireDate:[NSDate dateWithTimeIntervalSinceNow:5]];
}
- (void)changeImage{
    _index ++;
    [self stopTimer];
    
    __weak __typeof(self) ws= self;
    [self.detailOfChangeImage sd_setImageWithURL:[NSURL URLWithString:self.imagesArr[_index]] placeholderImage:[UIImage imageNamed:@"bookshelf_nodata"] options:SDWebImageContinueInBackground completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, NSURL *imageURL) {
        
        if (image) {
            NSLog(@"success");
            [ws beginTimer];
        }
    }];
    
    if (_index == self.imagesArr.count -1) {
        _index = 0;
    }
}

#######以圖片UIImage的形式存儲
對在容器應用中也存在的數據項而言,可以在容器應用緩存的同時保存一份到共享UserDefaults中,而Today Extension直接獲取。

不要存儲為NSData格式,因為不僅數據存儲量大,而且imageWithData:方法本身就是一個同步方法
 [UIImage imageWithData:data];

除了圖片的加載過程,也包括一些對新數據的網絡直接下載。如果下載量過大會直接導致數據不顯示,并且提示 --"無法載入"。

Today Widget提示“無法載入”
2.1 后臺下載分析

在官方文檔中反復的出現關于使用后臺下載的方法,并且解釋:

Users tend to return to the host app immediately after they finish their task in your app extension. If the task involves a potentially lengthy upload or download, you need to ensure that it can finish after your extension gets terminated. To perform an upload or download, use the NSURLSession class to create a URL session and initiate a background upload or download task.
用戶傾向在你的應用擴展中結束了他們的任務后立刻返回宿主應用。如果任務包括一個潛在的長期的下載或者上傳,你需要保證能在你的擴展終止后能完成。為了執行一個上傳或下載的任務,使用NSURLSession類創建一個URL會話并且初始化一個后臺上傳或下載任務

這么一解釋的話很有道理,但是Extension的后臺下載不同于應用,有一段很重要的話:

If you include the UIBackgroundModes key in your app extension’s Info.plist file, the extension will be rejected by the App Store. (To learn more about this key, see UIBackgroundModes.)
如果在你的應用擴展的Info.plist文件中包括UIBackgroundModes鍵,擴展將會被應用商店拒絕
開始后臺下載請求

其中Identifier就是后臺下載session會話的唯一標識符。 --->只在后臺下載中使用

sharedContainerIdentifier屬性指明下載緩存存放的位置 --->應用和應用擴展都能使用的共享文件

-(void)widgetPerformUpdateWithCompletionHandler:(void (^)(NCUpdateResult))completionHandler
{
//在整個應用中標識后臺會話。
    NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"backgroundConfi-extension"];
   //下載數據存儲在該共享容器中。 
    configuration.sharedContainerIdentifier = @"group.com.session.data";
    
    NSString *path = @"https://www.gitbook.com/download/pdf/book/frontendmasters/front-end-handbook";
    
    NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
//后臺下載必須是delegate方法,不能使用block
    NSURLSessionDownloadTask *task = [session downloadTaskWithURL:[NSURL URLWithString:path]];
    [task resume];
}
后臺下載數據回調
如果數據回調的過程中,應用擴展仍在運行中,則所有的數據回調都類似于普通的下載回調處理
-(void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location
{
    NSLog(@"%@",location.absoluteString);
}
-(void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite
{
    NSLog(@"當前下載量:::%lld 已經下載的總量:%lld",bytesWritten,totalBytesWritten);
}
如果在下載過程中應用擴展不再運行 ,這時候應用擴展會在短時間內終止,但是并不會影響數據的下載。
If the app has been terminated, it’s relaunched in the 
background. Your launch code should recreate the session, 
using the same identifier as before, to allow the system 
to reassociate the background download task with your 
session. Once the app has relaunched, the series of events
 is the same as if the app had been suspended and resumed, 
as discussed above.

如果應用被終止了,會在后臺中重新啟動。你的啟動代碼應用重新創建該會話,
使用之前一樣的標識符,允許系統將后臺下載任務和你的會話聯系在一起。
一旦應用重新啟動,這一系列的事件是一樣的當應用被終止和繼續。

所以這時候系統會在后臺重新啟動應用,并且調用-(void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)())completionHandler方法 ,在該方法中需要:
①將未完成的任務和創建的新的擁有相同標識符的session會話綁定
①保存回調的completionHandler
然后在AppleDelegate中以普通下載的方式對該下載的過程進行處理:

-(void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)())completionHandler
{
 if ([identifier isEqualToString:@"backgroundConfi-extension"]) {
        
        //官方文檔中也是說直接進行綁定即可,那么就是說任務會自動的運行
        NSURLSession *session = [self setSessionByUnCompleteSessionConfiId:identifier];
        
        NSLog(@"重新將session和task 連接 %@",session);
        
        if (!self.completionHandlerDictonary) {
            self.completionHandlerDictonary = [NSMutableDictionary dictionary];
        }
        self.completionHandlerDictonary[identifier] = completionHandler;
    }
}

- (NSURLSession *)setSessionByUnCompleteSessionConfiId:(NSString *)identifer
{
    static NSURLSession *session = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        NSURLSessionConfiguration *confi = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:identifer];
        session = [NSURLSession sessionWithConfiguration:confi delegate:self delegateQueue:[NSOperationQueue mainQueue]];
    });
    
    return session;
}

//后臺下載完成
-(void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session
{
    NSString *identifier = session.configuration.identifier;
    
    void (^handler)(void) = [self.completionHandlerDictonary objectForKey:identifier];
    
    if (handler) {
        [self.completionHandlerDictonary removeObjectForKey:identifier];
        NSLog(@"handler completion");
        
        dispatch_async(dispatch_get_main_queue(), ^{
            handler();
        });   
    }
}

-(void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location
{
    NSString *str = location.absoluteString;
    NSLog(@"application :::%@",str);
}

其實只不過將后臺下載放入了Extension的數據處理中,就有些難以處理的過程,也沒有搜到相關完整的處理,所以將實現的過程一一記錄下來。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,333評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,491評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,263評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,946評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,708評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,409評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,939評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,774評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,641評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,872評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,650評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容

  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,781評論 18 139
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,637評論 25 708
  • 發現 關注 消息 iOS 第三方庫、插件、知名博客總結 作者大灰狼的小綿羊哥哥關注 2017.06.26 09:4...
    肇東周閱讀 12,151評論 4 61
  • 在B2B的服務平臺,企業的組織架構是設計的一部分。首先我們需要了解企業的組織架構性質和企業組織架構的設計原則。 七...
    做個傻子看人間冷暖閱讀 2,822評論 0 3
  • 本書和作者 《黑天鵝》作者塔勒布的又一部全球暢銷經典,更被作者視為自己的畢生杰作。《思考快與慢》作者丹尼爾·卡尼曼...
    讓你更值錢閱讀 9,667評論 0 8