Flutter組件(Widget)的局部刷新方式

Flutter中有兩個(gè)常用的狀態(tài)Widget分為StatefulWidget和StatelessWidget,分別為動(dòng)態(tài)視圖和靜態(tài)視圖,視圖的更新需要調(diào)用StatefulWidget的setState方法,這會遍歷調(diào)用子Widget的build方法。如果一個(gè)頁面內(nèi)容比較復(fù)雜時(shí)锁右,會包含多個(gè)widget失受,如果直接調(diào)用setState讶泰,會遍歷所有子Widget的build咏瑟,這樣會造成很多不必要的開銷,所以非常有必要了解Flutter中局部刷新的方式:

通過GlobalKey局部刷新

globalkey唯一定義了某個(gè)element痪署,它使你能夠訪問與element相關(guān)聯(lián)的其他對象码泞,例如buildContext、state等狼犯。應(yīng)用場景:跨widget訪問狀態(tài)余寥。
例如:可以通過key.currentState拿到它的狀態(tài)對象,然后就可以調(diào)用其中的onPressed方法悯森。

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  GlobalKey<_TextWidgetState> textKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            TextWidget(textKey),// 需要更新的Text
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: (){
          _counter ++;// 這里我們只給他值變動(dòng)宋舷,狀態(tài)刷新交給下面的key事件
          textKey.currentState.onPressed(_counter);//這個(gè)counter的值已經(jīng)改變了,但是沒有重繪所以我們看到的知識我們定義的初始值
        },
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}
class TextWidget extends StatefulWidget {
  final Key key;
  const TextWidget(this.key);
  @override
  _TextWidgetState createState() => _TextWidgetState();
}

class _TextWidgetState extends State<TextWidget> {
  String _text = "0";
  @override
  Widget build(BuildContext context) {
    return Center(child: Text(_text, style: TextStyle(fontSize: 20),),);
  }

  void onPressed(int count) {
    setState(() {
      _text = count.toString();
    });
  }
}

ValueNotifier和ValueListenableBuilder

Flutter框架內(nèi)部提供了一個(gè)非常小巧精致的組件瓢姻,專門用于局部組件的刷新祝蝠。適用于值改動(dòng)的刷新。

class ValueNotifierTestPage extends StatefulWidget {
  @override
  _ValueNotifierTestPageState createState() => _ValueNotifierTestPageState();
}

class _ValueNotifierTestPageState extends State<ValueNotifierTestPage> {

  // 定義ValueNotifier<int> 對象 _counter
  final ValueNotifier<int> _counter = ValueNotifier<int>(0);

  void _incrementCounter(){
    _counter.value += 1;
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child:  Scaffold(
        appBar: AppBar(
          title: Text('ValueNotifierTestPage'),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              WidgetA(),
              Text( 'You have pushed the button this many times:'),
              ValueListenableBuilder<int>(
                builder: _buildWithValue,
                valueListenable: _counter,
              )
            ],
          ),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: _incrementCounter,
          child: Icon(Icons.add),
        ),
      ),
    );
  }

  Widget _buildWithValue(BuildContext context, int value, Widget child) {
    return Text(
      '$value',
      style: Theme.of(context).textTheme.headline4,
    );
  }

  @override
  void dispose() {
    _counter.dispose();
    super.dispose();
  }
}

class WidgetA extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Text('I am a widget that will not be rebuilt.');
  }
}

實(shí)現(xiàn)原理:在 initState 中對傳入的可監(jiān)聽對象進(jìn)行監(jiān)聽幻碱,執(zhí)行 _valueChanged 方法绎狭,_valueChanged 中進(jìn)行了 setState 來觸發(fā)當(dāng)前狀態(tài)的刷新。觸發(fā) build 方法褥傍,從而觸發(fā) widget.builder 回調(diào)儡嘶,這樣就實(shí)現(xiàn)了局部刷新』蟹纾可以看到這里回調(diào)的 child 是組件傳入的 child蹦狂,所以直接使用,這就是對 child 的優(yōu)化的根源朋贬。

可以看到 ValueListenableBuilder 實(shí)現(xiàn)局部刷新的本質(zhì)鸥咖,也是進(jìn)行組件的抽離,讓組件狀態(tài)的改變框定在狀態(tài)內(nèi)部兄世,并通過 builder 回調(diào)控制局部刷新啼辣,暴露給用戶使用。

StatefulBuilder局部刷新

通過這個(gè)可以創(chuàng)建一個(gè)支持局部刷新的widget樹御滩,比如你可以在StatelessWidget里面刷新某個(gè)布局鸥拧,但是不需要改變成StatefulWidget;也可以在StatefulWidget中使用做部分刷新而不需要刷新整個(gè)頁面削解,這個(gè)刷新是不會調(diào)用Widget build(BuildContext context)刷新整個(gè)布局樹的富弦。

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  StateSetter _reloadTextSetter;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            StatefulBuilder(builder: (BuildContext context, StateSetter stateSetter){
              _reloadTextSetter = stateSetter;
              return Text(_counter.toString());
            })// 需要更新的Text
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: (){
          _counter ++;
          _reloadTextSetter((){
          });
        },
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

FutureBuilder & StreamBuilder

異步UI更新:
很多時(shí)候我們會依賴一些異步數(shù)據(jù)來動(dòng)態(tài)更新UI,比如在打開一個(gè)頁面時(shí)我們需要先從互聯(lián)網(wǎng)上獲取數(shù)據(jù)氛驮,在獲取數(shù)據(jù)的過程中顯示一個(gè)加載框腕柜,等獲取到數(shù)據(jù)時(shí)我們再渲染頁面;又比如我們想展示Stream(比如文件流、互聯(lián)網(wǎng)數(shù)據(jù)接收流)的進(jìn)度盏缤。當(dāng)然StatefulWidget我們完全可以實(shí)現(xiàn)以上功能砰蠢。但由于在實(shí)際開發(fā)中依賴異步數(shù)據(jù)更新UI的這種場景非常常見,并且當(dāng)StatefulWidget中控件樹較大時(shí)唉铜,更新一個(gè)屬性導(dǎo)致整個(gè)樹重建台舱,消耗性能,因此Flutter專門提供了FutureBuilder和SteamBuilder兩個(gè)組件來快速實(shí)現(xiàn)這種功能潭流。

class _MyHomePageState extends State<MyHomePage> {

  Future<String> mockNetworkData() async {
    return Future.delayed(Duration(seconds: 2), () => "我是從互聯(lián)網(wǎng)上獲取的數(shù)據(jù)");
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            FutureBuilder(
              future: mockNetworkData(),
                builder: (BuildContext context, AsyncSnapshot snapshot){
              if(snapshot.connectionState == ConnectionState.done){
                if(snapshot.hasError){
                  // 請求失敗竞惋,顯示錯(cuò)誤
                  return Text("Error: ${snapshot.error}");
                }else {
                  // 請求成功,顯示數(shù)據(jù)
                  return Text("Contents: ${snapshot.data}");
                }
              }else {
                return CircularProgressIndicator();
              }
            }),
          ],
        ),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}
class _MyHomePageState extends State<MyHomePage> {

  Stream<int> counter(){
    return Stream.periodic(Duration(seconds: 1), (i){
      return i;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            StreamBuilder(
                stream: counter(),
                builder: (BuildContext context, AsyncSnapshot<int> snapshot){
                  if(snapshot.hasError){
                    return Text("Error: ${snapshot.error}");
                  }

                  switch (snapshot.connectionState){
                    case ConnectionState.none:
                      return Text("沒有Stream");
                    case ConnectionState.waiting:
                      return Text("等待數(shù)據(jù)灰嫉、拆宛、、");
                    case ConnectionState.active:
                      return Text("active: ${snapshot.data}");
                    case ConnectionState.done:
                      return Text("Stream已關(guān)閉");
                  }
                  return null;
                }),
          ],
        ),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

InheritedWidget

通常情況下讼撒,子Widget無法單獨(dú)感知父Widget的變化胰挑,當(dāng)父state變化時(shí),通過其build重建所有子widget椿肩;
InheriteddWidget可以避免這種全局創(chuàng)建瞻颂,實(shí)現(xiàn)局部子Widget更新。InheritedWidget提供了一種在Widget樹中從上到下傳遞郑象、共享數(shù)據(jù)的方式贡这。Flutter SDK正是通過InheritedWidget來共享應(yīng)用主題和Locale等信息。
InheritedWidgetData

class InheritedWidgetData<T> extends InheritedWidget {
  InheritedWidgetData({
    Key key,
    @required Widget child,
    @required this.data,
  }) : super(key: key, child: child);

  final T data;
  @override
  bool updateShouldNotify(InheritedWidgetData<T> oldWidget) {
    return true;
  }
}

TestData

class TestData extends StatefulWidget {
  TestData({
    Key key,
    this.child,
  }) : super(key: key);

  final Widget child;

  @override
  _TestDataState createState() => _TestDataState();


  static _TestDataState of(BuildContext context, {bool rebuild = true}) {
    if (rebuild) {
      return context.dependOnInheritedWidgetOfExactType<InheritedWidgetData<_TestDataState>>().data;
    }
    return context.findAncestorWidgetOfExactType<InheritedWidgetData<_TestDataState>>().data;
  }


}

class _TestDataState extends State<TestData> {
  int counter = 0;

  void _incrementCounter() {
    setState(() {
      counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return InheritedWidgetData(
      data: this,
      child: widget.child,
    );
  }
}

InheritedTest1Page

import 'package:flutter/material.dart';

class InheritedTest1Page extends StatefulWidget {
  @override
  _InheritedTest1PageState createState() => _InheritedTest1PageState();
}

class _InheritedTest1PageState extends State<InheritedTest1Page> {
  @override
  Widget build(BuildContext context) {
    return Center(
      child:  Scaffold(
        appBar: AppBar(
          title: Text('InheritedWidgetTest'),
        ),
        body: TestData(
          child: Column(
            children: <Widget>[
              Padding(
                padding: const EdgeInsets.only(left: 10.0, top: 10.0, right: 10.0),
                child: Text('我們常使用的\nTheme.of(context).textTheme\nMediaQuery.of(context).size等\n就是通過InheritedWidget實(shí)現(xiàn)的',
                  style: Theme.of(context).textTheme.title,),
              ),
              WidgetA(),
              WidgetB(),
              WidgetC(),
            ],
          ),
        ),
      ),
    );
  }
}
class WidgetA extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
  final _TestDataState state = TestData.of(context);

    return Center(
      child: Text(
        '${state.counter}',
        style: Theme.of(context).textTheme.display1,
      ),
    );
  }
}

class WidgetB extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Text('I am a widget that will not be rebuilt.');
  }
}

class WidgetC extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
  final _TestDataState state = TestData.of(context, rebuild: false);
    return RaisedButton(
      onPressed: () {
        state._incrementCounter();
      },
      child: Icon(Icons.add),
    );
  }
}

ChangeNotifierProvider(Provider)

provider是Google I/O 2019大會上宣布的現(xiàn)在官方推薦的管理方式厂榛,而ChangeNotifierProvider可以說是Provider的一種:
yaml文件需要引入provider: ^3.1.0
頂層嵌套ChangeNotifierProvider

void main(){
  runApp(ChangeNotifierProvider(builder: (context) => DataInfo(), child: MyApp(),));
}

創(chuàng)建共享數(shù)據(jù)類DataInfo:
數(shù)據(jù)類需要with ChangeNotifier 以使用 notifyListeners()函數(shù)通知監(jiān)聽者更新界面盖矫。

class DataInfo with ChangeNotifier {
  int _count = 0;

  get count => _count;

  addCount(){
    _count ++;
    notifyListeners();
  }

  subCount(){
    _count --;
    notifyListeners();
  }
}

使用Provider.of(context)獲取DataInfo

@override
  Widget build(BuildContext context) {
    var dataInfo = Provider.of<DataInfo>(context);
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(

          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            FloatingActionButton(
              heroTag: "add",
              child: Icon(Icons.add),
                onPressed: (){
                dataInfo.addCount();
                }
                ),
            Text(
              '${dataInfo.count}',
            ),
            FloatingActionButton(
              heroTag: "sub",
              child: Icon(Icons.add),
                onPressed: (){
                dataInfo.subCount();
                })
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: (){
          Navigator.of(context).push(CupertinoPageRoute(builder: (con){
            return NextPage();
          }));

        },
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }

nextPage:
使用Consumer包住需要使用共享數(shù)據(jù)的Widget

@override
Widget build(BuildContext context) {
  return Consumer<DataInfo>(
    builder: (context, dataInfo, _){
      return Scaffold(
        appBar: AppBar(
          title: Text("next"),
        ),
        body: Center(
          child: Column(

            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              FloatingActionButton(
                  heroTag: "add",
                  child: Icon(Icons.add),
                  onPressed: (){
                dataInfo.addCount();
                  }
              ),
              Text(
            '${dataInfo.count}',
              ),
              FloatingActionButton(
                  heroTag: "sub",
                  child: Icon(Icons.add),
                  onPressed: (){
                dataInfo.subCount();
                  })
            ],
          ),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: (){
            Navigator.of(context).pop();
          },
          tooltip: 'Increment',
          child: Icon(Icons.settings_input_composite),
        ), // This trailing comma makes auto-formatting nicer for build methods.
      );
    },
  );
}

RepaintBoundary

RepaintBoundary就是重繪邊界,用于重繪時(shí)獨(dú)立于父視圖击奶。頁面需要更新的頁面結(jié)構(gòu)可以用 RepaintBoundary組件嵌套,flutter 會將包含的組件獨(dú)立出一層"畫布"辈双,去繪制。官方很多組件 外層也包了層 RepaintBoundary 標(biāo)簽柜砾。如果你的自定義view比較復(fù)雜湃望,應(yīng)該盡可能的避免重繪。

void markNeedsPaint() {
    if (_needsPaint)
      return;
    _needsPaint = true;
    if (isRepaintBoundary) { // 為true時(shí)痰驱,直接合成視圖证芭,避免重繪
      if (owner != null) {
        owner._nodesNeedingPaint.add(this);
        owner.requestVisualUpdate();
      }
    } else if (parent is RenderObject) {
      final RenderObject parent = this.parent;
      parent.markNeedsPaint();
      assert(parent == this.parent);
    } else {
      if (owner != null)
        owner.requestVisualUpdate();
    }
  }
class RepainBoundaryPage extends StatefulWidget {
  @override
  _RepainBoundaryPageState createState() => _RepainBoundaryPageState();
}

class _RepainBoundaryPageState extends State<RepainBoundaryPage> {
  @override
  Widget build(BuildContext context) {
    return Center(
      child:  Scaffold(
        appBar: AppBar(
          title: Text('RepainBoundaryPage'),
        ),
        body: Column(
          children: <Widget>[
            WidgetA(),
            WidgetB(),
            WidgetC(),
          ],
        ),
      ),
    );
  }
}
class WidgetC extends StatefulWidget {
  @override
  _WidgetCState createState() => _WidgetCState();
}

class _WidgetCState extends State<WidgetC> {
  int counter = 0;
  @override
  Widget build(BuildContext context) {
    return RepaintBoundary(child: Column(
      children: [
        Text('~~~${counter}'),
        RaisedButton(
          onPressed: () {
            setState(() {
              counter++;
            });
          },
          child: Icon(Icons.add),
        )
      ],
    ),);
  }
}

以上總結(jié)了幾種Flutter的局部刷新的方式,可根據(jù)實(shí)際需要使用不同的方式担映,最適合的才是最好的废士。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市蝇完,隨后出現(xiàn)的幾起案子官硝,更是在濱河造成了極大的恐慌矗蕊,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,198評論 6 514
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件氢架,死亡現(xiàn)場離奇詭異傻咖,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)达箍,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,334評論 3 398
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來铺厨,“玉大人缎玫,你說我怎么就攤上這事〗庾遥” “怎么了赃磨?”我有些...
    開封第一講書人閱讀 167,643評論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長洼裤。 經(jīng)常有香客問我邻辉,道長,這世上最難降的妖魔是什么腮鞍? 我笑而不...
    開封第一講書人閱讀 59,495評論 1 296
  • 正文 為了忘掉前任值骇,我火速辦了婚禮,結(jié)果婚禮上移国,老公的妹妹穿的比我還像新娘吱瘩。我一直安慰自己,他們只是感情好迹缀,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,502評論 6 397
  • 文/花漫 我一把揭開白布使碾。 她就那樣靜靜地躺著,像睡著了一般祝懂。 火紅的嫁衣襯著肌膚如雪票摇。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,156評論 1 308
  • 那天砚蓬,我揣著相機(jī)與錄音矢门,去河邊找鬼。 笑死灰蛙,一個(gè)胖子當(dāng)著我的面吹牛颅和,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播缕允,決...
    沈念sama閱讀 40,743評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼峡扩,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了障本?” 一聲冷哼從身側(cè)響起教届,我...
    開封第一講書人閱讀 39,659評論 0 276
  • 序言:老撾萬榮一對情侶失蹤响鹃,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后案训,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體买置,經(jīng)...
    沈念sama閱讀 46,200評論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,282評論 3 340
  • 正文 我和宋清朗相戀三年强霎,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了忿项。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,424評論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡城舞,死狀恐怖轩触,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情家夺,我是刑警寧澤脱柱,帶...
    沈念sama閱讀 36,107評論 5 349
  • 正文 年R本政府宣布,位于F島的核電站拉馋,受9級特大地震影響榨为,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜煌茴,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,789評論 3 333
  • 文/蒙蒙 一随闺、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧蔓腐,春花似錦板壮、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,264評論 0 23
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至透葛,卻和暖如春笨使,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背僚害。 一陣腳步聲響...
    開封第一講書人閱讀 33,390評論 1 271
  • 我被黑心中介騙來泰國打工硫椰, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人萨蚕。 一個(gè)月前我還...
    沈念sama閱讀 48,798評論 3 376
  • 正文 我出身青樓靶草,卻偏偏與公主長得像,于是被迫代替她去往敵國和親岳遥。 傳聞我的和親對象是個(gè)殘疾皇子奕翔,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,435評論 2 359

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

  • 這里說的Flutter中Widget之間的聯(lián)動(dòng)方式是指一個(gè)Widget更新后,另一個(gè)Widget得到響應(yīng)并更新浩蓉,我...
    twj小魚兒閱讀 3,363評論 0 4
  • 一派继、為什么會有Provider宾袜? 因?yàn)?Flutter 與 React 技術(shù)棧的相似性,所以在 Flutter 中...
    歐陽公子閱讀 4,458評論 0 3
  • 為什么我們需要狀態(tài)管理 如果應(yīng)用足夠簡單驾窟,F(xiàn)lutter 作為一個(gè)聲明式框架庆猫,你或許只需要將 數(shù)據(jù) 映射成 視圖 ...
    誰在烽煙彼岸閱讀 12,235評論 1 9
  • 在響應(yīng)式編程中,狀態(tài)即數(shù)據(jù)绅络,狀態(tài)變化月培,頁面即發(fā)生變化,F(xiàn)lutter作為響應(yīng)式開發(fā)框架恩急,狀態(tài)管理是Flutter開...
    陜E_沙僧閱讀 1,960評論 0 0
  • 目前學(xué)習(xí)開發(fā)flutter已經(jīng)有快1年的時(shí)間了杉畜,大大小小也做了七七八八個(gè)項(xiàng)目。項(xiàng)目從大到小都有假栓,之前公司開發(fā)的項(xiàng)目...
    iOS超級洋閱讀 1,551評論 0 6