flutter FutureBuilder的使用以及防止FutureBuilder不必要重繪的兩種方法

前言:

我們經(jīng)常有這樣的一個(gè)開(kāi)發(fā)場(chǎng)景:一個(gè)頁(yè)面進(jìn)入之后先進(jìn)行網(wǎng)絡(luò)請(qǐng)求,此時(shí)顯示一個(gè)圓圈(等待動(dòng)畫)倍啥,等網(wǎng)絡(luò)數(shù)據(jù)返回時(shí)顯示一個(gè)展示網(wǎng)絡(luò)數(shù)據(jù)的布局虽缕。例如下圖:

我們通常的做法是

if(data==null){
    return CircularProgressIndicator();
}else{
    return ListView(...);
}

大致就是數(shù)據(jù)返回之前我們加載一個(gè)組件氮趋,等數(shù)據(jù)返回值后,我們重繪頁(yè)面返回另一個(gè)組件诉植。
在flutter中晾腔,有一個(gè)新的實(shí)現(xiàn)方式,那就是我們即將要介紹的futureBuilder.

FutureBuilder用法和實(shí)現(xiàn)

Widget that builds itself based on the latest snapshot of interaction with a Future.

官方意思是一個(gè)基于與Future交互的最新快照構(gòu)建自己的小部件壁查。

先看一下它的構(gòu)造方法:

  const FutureBuilder({
    Key key,
    this.future,          //獲取數(shù)據(jù)的方法
    this.initialData,   //初始的默認(rèn)數(shù)據(jù)
    @required this.builder
  }) : assert(builder != null),
       super(key: key);

主要看一下builder睡腿,這個(gè)是我們主要關(guān)心的嫉到,它是我們構(gòu)建組件的策略何恶。
接收兩個(gè)參數(shù):BuildContext context, AsyncSnapshot snapshot.
context就不解釋了嚼黔,snapshot就是_calculation在時(shí)間軸上執(zhí)行過(guò)程的狀態(tài)快照。

//FutureBuilder控件
new FutureBuilder<String>(
  future: _calculation, // 用戶定義的需要異步執(zhí)行的代碼疫赎,類型為Future<String>或者null的變量或函數(shù)
  builder: (BuildContext context, AsyncSnapshot<String> snapshot) {      //snapshot就是_calculation在時(shí)間軸上執(zhí)行過(guò)程的狀態(tài)快照
    switch (snapshot.connectionState) {
      case ConnectionState.none: return new Text('Press button to start');    //如果_calculation未執(zhí)行則提示:請(qǐng)點(diǎn)擊開(kāi)始
      case ConnectionState.waiting: return new Text('Awaiting result...');  //如果_calculation正在執(zhí)行則提示:加載中
      default:    //如果_calculation執(zhí)行完畢
        if (snapshot.hasError)    //若_calculation執(zhí)行出現(xiàn)異常
          return new Text('Error: ${snapshot.error}');
        else    //若_calculation執(zhí)行正常完成
          return new Text('Result: ${snapshot.data}');
    }
  },
)

FutureBuilder通過(guò)子屬性future獲取用戶需要異步處理的代碼捧搞,用builder回調(diào)函數(shù)暴露出異步執(zhí)行過(guò)程中的快照胎撇。我們通過(guò)builder的參數(shù)snapshot暴露的快照屬性晚树,定義好對(duì)應(yīng)狀態(tài)下的處理代碼雅采,即可實(shí)現(xiàn)異步執(zhí)行時(shí)的交互邏輯婚瓜。

看起來(lái)似乎有點(diǎn)繞口,我們看看下面這段代碼:

/*
 * Created by 李卓原 on 2018/9/30.
 * email: zhuoyuan93@gmail.com
 * 關(guān)于狀態(tài)改變引起的不必要的頁(yè)面刷新:https://github.com/flutter/flutter/issues/11426#issuecomment-414047398
 */

import 'dart:async';

import 'package:async/async.dart';
import 'package:flutter/material.dart';
import 'package:flutter_app/utils/HttpUtil.dart';

class FutureBuilderPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => FutureBuilderState();
}

class FutureBuilderState extends State<FutureBuilderPage> {
  String title = 'FutureBuilder使用';
  
  Future _gerData() async {
    var response = HttpUtil()
        .get('http://api.douban.com/v2/movie/top250', data: {'count': 15});
    return response;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          setState(() {
            title = title + '.';
          });
        },
        child: Icon(Icons.title),
      ),
    body: FutureBuilder(
        builder: _buildFuture,
        future: _gerData(), // 用戶定義的需要異步執(zhí)行的代碼,類型為Future<String>或者null的變量或函數(shù)
      ),
    );
  }

  ///snapshot就是_calculation在時(shí)間軸上執(zhí)行過(guò)程的狀態(tài)快照
  Widget _buildFuture(BuildContext context, AsyncSnapshot snapshot) {
    switch (snapshot.connectionState) {
      case ConnectionState.none:
        print('還沒(méi)有開(kāi)始網(wǎng)絡(luò)請(qǐng)求');
        return Text('還沒(méi)有開(kāi)始網(wǎng)絡(luò)請(qǐng)求');
      case ConnectionState.active:
        print('active');
        return Text('ConnectionState.active');
      case ConnectionState.waiting:
        print('waiting');
        return Center(
          child: CircularProgressIndicator(),
        );
      case ConnectionState.done:
        print('done');
        if (snapshot.hasError) return Text('Error: ${snapshot.error}');
        return _createListView(context, snapshot);
      default:
        return null;
    }
  }

  Widget _createListView(BuildContext context, AsyncSnapshot snapshot) {
    List movies = snapshot.data['subjects'];
    return ListView.builder(
      itemBuilder: (context, index) => _itemBuilder(context, index, movies),
      itemCount: movies.length * 2,
    );
  }

  Widget _itemBuilder(BuildContext context, int index, movies) {
    if (index.isOdd) {
      return Divider();
    }
    index = index ~/ 2;
    return ListTile(
      title: Text(movies[index]['title']),
      leading: Text(movies[index]['year']),
      trailing: Text(movies[index]['original_title']),
    );
  }
}

在build方法中茂附,我們返回了一個(gè)Scaffold,主要的代碼在body中营曼,包裹了一個(gè)FutureBuilder,
我們?cè)谒腷uilder方法中锻全,對(duì)不同狀態(tài)返回了不同的控件鳄厌。

snapshot.connectionState就是異步函數(shù)_gerData的執(zhí)行狀態(tài)妈踊,用戶通過(guò)定義在ConnectionState.noneConnectionState.waiting狀態(tài)下廊营,輸出一個(gè)Text和居中·(Center)·顯示并且內(nèi)置文字CircularProgressIndicator的組件,其意義即:當(dāng)異步函數(shù)_gerData未執(zhí)行時(shí)呐伞,屏幕正中央顯示文字:還沒(méi)有開(kāi)始網(wǎng)絡(luò)請(qǐng)求伶氢。和正在執(zhí)行時(shí),顯示一個(gè)刷新?tīng)顟B(tài)的控件鞍历。

當(dāng)_gerData執(zhí)行完畢后,snapshot.connectionState的值即變?yōu)?code>ConnectionState.done扇救,此時(shí)即可輸出根據(jù)HTTP請(qǐng)求獲取到的數(shù)據(jù)生成對(duì)應(yīng)的ListItem迅腔。由于ConnectionState.done是除了ConnectionState.noneConnectionState.waiting以外的唯一值靠娱,所以代碼中在switch下用default也可(ConnectionState.active好像在整個(gè)過(guò)程中沒(méi)有調(diào)用)。

由于通過(guò)FutureBuilder內(nèi)的builder()函數(shù)即可操控控件的狀態(tài)和重繪锌雀,我們不必通過(guò)自己寫異步狀態(tài)的判斷和多次使用setState()實(shí)現(xiàn)頁(yè)面上加載中和加載完成顯示效果的切換腋逆,因?yàn)?code>FutureBuilder內(nèi)部自帶了執(zhí)行setState()的方法。

現(xiàn)在一個(gè)FutureBuilder的構(gòu)建就算完成了等脂。

防止FutureBuilder進(jìn)行不必要的重繪

如果只是寫一個(gè)FutureBuilder,我們就不需要floatingActionButton里的一系列東西上遥,所以這時(shí)候就到它的出場(chǎng)了粉楚。
代碼中的意思,每次點(diǎn)擊它解幼,就在我們標(biāo)題后面加一個(gè)“.” , 看一下效果


確實(shí)是改變了標(biāo)題撵摆,但是整個(gè)頁(yè)面也隨著setState而進(jìn)行了不必要的重繪特铝,這就是我們本篇的重點(diǎn)了壹瘟。

即使AppBar和FutureBuilder沒(méi)有任何關(guān)聯(lián)稻轨,每次我們改變它的值(通過(guò)調(diào)用setState)殴俱, FutureBuilder都會(huì)再次經(jīng)歷整個(gè)生命周期!它重新取代future明场,導(dǎo)致不必要的流量苦锨,并再次顯示負(fù)載,導(dǎo)致糟糕的用戶體驗(yàn)拉庶。

這個(gè)問(wèn)題以各種方式表現(xiàn)出來(lái)砍的。在某些情況下莺治,它甚至不像上面的例子那么明顯。例如:

  • 從當(dāng)前不在屏幕上的頁(yè)面生成的網(wǎng)絡(luò)流量
  • 熱重裝不能正常工作
  • 更新某些“繼承的窗口小部件”中的值時(shí)丟失導(dǎo)航器狀態(tài)
  • 等等…

但是這一切的原因是什么床佳?我們?nèi)绾谓鉀Q它砌们?

didUpdateWidget問(wèn)題

注意:在本節(jié)中搁进,我將詳細(xì)介紹FutureBuilder的工作原理饼问。如果您對(duì)此不感興趣莱革,可以跳到解決方案。

如果我們仔細(xì)看看代碼FutureBuilder捐名,我們發(fā)現(xiàn)它是一個(gè)StatefulWidget镶蹋。我們知道贺归,StatefulWidgets維護(hù)一個(gè)長(zhǎng)期存在的State對(duì)象除破。這種狀態(tài)有一些管理其生命周期的方法瑰枫,就像方法initState光坝,builddidUpdateWidget盯另。

initState在第一次創(chuàng)建狀態(tài)對(duì)象時(shí)只調(diào)用一次鸳惯,并且build每次我們需要構(gòu)建要顯示的窗口小部件時(shí)調(diào)用它芝发,但是那是什么didUpdateWidget呢?只要附加到此State對(duì)象的窗口小部件發(fā)生更改格郁,就會(huì)調(diào)用此方法例书。
當(dāng)使用新輸入重建窗口小部件時(shí)决采,將放置舊窗口小部件织狐,并創(chuàng)建新窗口小部件并將其分配給State對(duì)象,并didUpdateWidget在重建之前調(diào)用它以執(zhí)行我們想要執(zhí)行的任何操作筏勒。

FutureBuilder這種情況下厨埋,這個(gè)方法看起來(lái)像這樣:

@override
void didUpdateWidget(FutureBuilder<T> oldWidget) {
  super.didUpdateWidget(oldWidget);
  if (oldWidget.future != widget.future) {
    if (_activeCallbackIdentity != null) {
      _unsubscribe();
      _snapshot = _snapshot.inState(ConnectionState.none);
    }
    _subscribe();
  }
}

它基本上是說(shuō):如果在重建時(shí)捐顷,新窗口小部件具有與舊窗口小部件不同的Future實(shí)例废赞,則重復(fù)所有內(nèi)容:取消訂閱唉地,并再次訂閱。

但我們不是提供相同的Future嗎耘沼?我們稱之為同一個(gè)功能菠隆!好吧骇径,F(xiàn)uture的情況不一樣了既峡。我們的功能正在完成同樣的工作运敢,但隨后又回歸了一個(gè)與舊的不同的新Future忠售。

因此传惠,我們想要做的是在第一次調(diào)用時(shí)存儲(chǔ)或緩存函數(shù)的輸出,然后在再次調(diào)用函數(shù)時(shí)提供相同的輸出稻扬。此過(guò)程稱為記憶(memoization)卦方。

解決方案 1 :Memoize the future

簡(jiǎn)單來(lái)說(shuō),Memoization緩存函數(shù)的返回值泰佳,并在再次調(diào)用該函數(shù)時(shí)重用它盼砍。Memoization主要用于函數(shù)式語(yǔ)言,其中函數(shù)是確定性的(它們總是為相同的輸入返回相同的輸出)逝她,但我們可以在這里使用簡(jiǎn)單的memoization來(lái)解決我們的問(wèn)題浇坐,以確保FutureBuilder始終接收相同的未來(lái)實(shí)例。

為此黔宛,我們將使用DartAsyncMemoizer觉渴。這個(gè)記憶器完全符合我們的要求!它需要一個(gè)異步函數(shù)回右,在第一次調(diào)用它時(shí)調(diào)用它渺氧,并緩存其結(jié)果。對(duì)于該函數(shù)的所有后續(xù)調(diào)用弧腥,memoizer返回相同的先前計(jì)算的未來(lái)铡买。

因此澡为,為了解決我們的問(wèn)題谷徙,我們首先在我們的小部件中創(chuàng)建一個(gè)AsyncMemoizer實(shí)例:

final AsyncMemoizer _memoizer = AsyncMemoizer();

注意:你不應(yīng)該在StatelessWidget中實(shí)例化memoizer,因?yàn)镕lutter在每次重建時(shí)都會(huì)處理StatelessWidgets蛤织,這基本上可以達(dá)到目的摊鸡。您應(yīng)該在StatefulWidget中實(shí)例化它是辕,或者在它可以持久化的地方實(shí)例化它。

之后,我們將修改_fetchData函數(shù)以使用該memoizer:

_gerData() {
    return _memoizer.runOnce(() async {
      return await HttpUtil()
          .get('http://api.douban.com/v2/movie/top250', data: {'count': 15});
    });
 }

我們用AsyncMemoizer.runOnce包裝我們的函數(shù),它完全聽(tīng)起來(lái)像它的聲音;它只運(yùn)行一次該函數(shù)裸弦,并在再次調(diào)用時(shí)返回緩存的Future。
就是這樣!我們的FutureBuilder現(xiàn)在只是第一次觸發(fā):

現(xiàn)在,我們其他地方進(jìn)行setState也不會(huì)導(dǎo)致FutureBuilder的重繪了槽奕。
為了解決這個(gè)問(wèn)題囱持,我們使用Dart的AsyncMemoizer每次都傳遞相同的Future實(shí)例掩幢。

解決方法2 在構(gòu)建函數(shù)之外調(diào)用Future

問(wèn)題是每次發(fā)布重建時(shí)都會(huì)調(diào)用FutureBuilder狀態(tài)的didUpdateWidget芍阎。此函數(shù)檢查舊的future對(duì)象是否與新的對(duì)象不同肿轨,如果是藻茂,則重新啟動(dòng)FutureBuilder优俘。為了解決這個(gè)問(wèn)題叶雹,我們可以在構(gòu)建函數(shù)之外的某個(gè)地方調(diào)用Future沾瓦。例如缕探,在initState中豁鲤,將其保存在成員變量中,并將此變量傳遞給FutureBuilder炫狱。

比如:

var _futureBuilderFuture;
...

@override
void initState() { 
    ///用_futureBuilderFuture來(lái)保存_gerData()的結(jié)果,以避免不必要的ui重繪
    _futureBuilderFuture = _gerData();
  }
...

FutureBuilder(
  future: _futureBuilderFuture ,
  ....

這里使用_futureBuilderFuture來(lái)保存_gerData()的結(jié)果视译,這樣我們傳遞給FutureBuilder的是一個(gè)成員變量限番,而不是一個(gè)方法就不會(huì)多次調(diào)用了。
看一下完整代碼:

/*
* Created by 李卓原 on 2018/9/30.
* email: zhuoyuan93@gmail.com
* 關(guān)于狀態(tài)改變引起的不必要的頁(yè)面刷新:https://github.com/flutter/flutter/issues/11426#issuecomment-414047398
*/

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_app/utils/HttpUtil.dart';

class FutureBuilderPage extends StatefulWidget {
 @override
 State<StatefulWidget> createState() => FutureBuilderState();
}

class FutureBuilderState extends State<FutureBuilderPage> {
 String title = 'FutureBuilder使用';
 var _futureBuilderFuture;

 Future _gerData() async {
   var response = HttpUtil()
       .get('http://api.douban.com/v2/movie/top250', data: {'count': 15});
   return response;
 }

 @override
 void initState() {
   // TODO: implement initState
   super.initState();

   ///用_futureBuilderFuture來(lái)保存_gerData()的結(jié)果惜互,以避免不必要的ui重繪
   _futureBuilderFuture = _gerData();
 }

 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(
       title: Text(title),
     ),
     floatingActionButton: FloatingActionButton(
       onPressed: () {
         setState(() {
           title = title + '.';
         });
       },
       child: Icon(Icons.title),
     ),
     body: RefreshIndicator(
       onRefresh: _gerData,
       child: FutureBuilder(
         builder: _buildFuture,
         future:
             _futureBuilderFuture, // 用戶定義的需要異步執(zhí)行的代碼鲁沥,類型為Future<String>或者null的變量或函數(shù)
       ),
     ),
   );
 }

 ///snapshot就是_calculation在時(shí)間軸上執(zhí)行過(guò)程的狀態(tài)快照
 Widget _buildFuture(BuildContext context, AsyncSnapshot snapshot) {
   switch (snapshot.connectionState) {
     case ConnectionState.none:
       print('還沒(méi)有開(kāi)始網(wǎng)絡(luò)請(qǐng)求');
       return Text('還沒(méi)有開(kāi)始網(wǎng)絡(luò)請(qǐng)求');
     case ConnectionState.active:
       print('active');
       return Text('ConnectionState.active');
     case ConnectionState.waiting:
       print('waiting');
       return Center(
         child: CircularProgressIndicator(),
       );
     case ConnectionState.done:
       print('done');
       if (snapshot.hasError) return Text('Error: ${snapshot.error}');
       return _createListView(context, snapshot);
     default:
       return Text('還沒(méi)有開(kāi)始網(wǎng)絡(luò)請(qǐng)求');
   }
 }

 Widget _createListView(BuildContext context, AsyncSnapshot snapshot) {
   List movies = snapshot.data['subjects'];
   return ListView.builder(
     itemBuilder: (context, index) => _itemBuilder(context, index, movies),
     itemCount: movies.length * 2,
   );
 }

 Widget _itemBuilder(BuildContext context, int index, movies) {
   if (index.isOdd) {
     return Divider();
   }
   index = index ~/ 2;
   return ListTile(
     title: Text(movies[index]['title']),
     leading: Text(movies[index]['year']),
     trailing: Text(movies[index]['original_title']),
   );
 }
}
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市抑胎,隨后出現(xiàn)的幾起案子呕臂,更是在濱河造成了極大的恐慌萝映,老刑警劉巖,帶你破解...
    沈念sama閱讀 212,816評(píng)論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異箩绍,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)掷邦,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,729評(píng)論 3 385
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人讶迁,你說(shuō)我怎么就攤上這事徙鱼∩粝#” “怎么了拐揭?”我有些...
    開(kāi)封第一講書(shū)人閱讀 158,300評(píng)論 0 348
  • 文/不壞的土叔 我叫張陵讨衣,是天一觀的道長(zhǎng)娘汞。 經(jīng)常有香客問(wèn)我揩页,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 56,780評(píng)論 1 285
  • 正文 為了忘掉前任戈抄,我火速辦了婚禮,結(jié)果婚禮上后专,老公的妹妹穿的比我還像新娘划鸽。我一直安慰自己,他們只是感情好戚哎,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,890評(píng)論 6 385
  • 文/花漫 我一把揭開(kāi)白布裸诽。 她就那樣靜靜地躺著,像睡著了一般型凳。 火紅的嫁衣襯著肌膚如雪丈冬。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 50,084評(píng)論 1 291
  • 那天甘畅,我揣著相機(jī)與錄音埂蕊,去河邊找鬼实夹。 笑死,一個(gè)胖子當(dāng)著我的面吹牛粒梦,可吹牛的內(nèi)容都是我干的亮航。 我是一名探鬼主播,決...
    沈念sama閱讀 39,151評(píng)論 3 410
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼匀们,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼缴淋!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起泄朴,我...
    開(kāi)封第一講書(shū)人閱讀 37,912評(píng)論 0 268
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤重抖,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后祖灰,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體钟沛,經(jīng)...
    沈念sama閱讀 44,355評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,666評(píng)論 2 327
  • 正文 我和宋清朗相戀三年局扶,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了恨统。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,809評(píng)論 1 341
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡三妈,死狀恐怖畜埋,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情畴蒲,我是刑警寧澤悠鞍,帶...
    沈念sama閱讀 34,504評(píng)論 4 334
  • 正文 年R本政府宣布,位于F島的核電站模燥,受9級(jí)特大地震影響咖祭,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜蔫骂,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 40,150評(píng)論 3 317
  • 文/蒙蒙 一么翰、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧纠吴,春花似錦硬鞍、人聲如沸慧瘤。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,882評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)锅减。三九已至糖儡,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間怔匣,已是汗流浹背握联。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 32,121評(píng)論 1 267
  • 我被黑心中介騙來(lái)泰國(guó)打工桦沉, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人金闽。 一個(gè)月前我還...
    沈念sama閱讀 46,628評(píng)論 2 362
  • 正文 我出身青樓纯露,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親代芜。 傳聞我的和親對(duì)象是個(gè)殘疾皇子埠褪,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,724評(píng)論 2 351

推薦閱讀更多精彩內(nèi)容