Flutter 入門指北(Part 11)之狀態管理,BLoC

kukyxs發表於2019-04-14

該文已授權公眾號 「碼個蛋」,轉載請指明出處 講了那麼多的部件,這節我打算來點不太一樣的,可能會沒有部件那麼好理解,也可能是我講的不夠簡單明瞭,總之繫好安全帶,我們要準備開車了。

Stream

dart 部分記得分享過 Stream 的文章連結,但是我知道你們肯定沒幾個願意看的,所以這裡再提下。還是得從原始碼開始...因為原始碼的註釋比較長,就不貼註釋了,可以自己看,我這邊就提取一些關鍵資訊。

StreamDart 提供的一種資料流訂閱管理的"工具",感覺有點像 Android 中的 EventBus 或者 RxBusStream 可以接收任何物件,包括是另外一個 Stream,接收的物件通過 StreamControllersink 進行新增,然後通過 StreamController 傳送給 Stream,通過 listen 進行監聽,listen 會返回一個 StreamSubscription 物件,StreamSubscription 可以操作對資料流的監聽,例如 pauseresumecancel 等。

Stream 分兩種型別:

  1. Single-subscription Stream:單訂閱 stream,整個生命週期只允許有一個監聽,如果該監聽 cancel 了,也不能再新增另一個監聽,而且只有當有監聽了,才會傳送資料,主要用於檔案 IO 流的讀取等。
  2. Broadcast Stream:廣播訂閱 stream,允許有多個監聽,當新增了監聽後,如果流中有資料存在就可以監聽到資料,這種型別,不管是否有監聽,只要有資料就會傳送,用於需要多個監聽的情況。

還是看下例子會比較直觀

class _StreamHomeState extends State<StreamHome> {
  StreamController _controller = StreamController();  // 建立單訂閱型別 `StreamController`
  Sink _sink;
  StreamSubscription _subscription;

  @override
  void initState() {
    super.initState();

    _sink = _controller.sink; // _sink 用於新增資料
    // _controller.stream 會返回一個單訂閱 stream,
    // 通過 listen 返回 StreamSubscription,用於操作流的監聽操作
    _subscription = _controller.stream.listen((data) => print('Listener: $data'));

    // 新增資料,stream 會通過 `listen` 方法列印
    _sink.add('A');
    _sink.add(11);
    _sink.add(11.16);
    _sink.add([1, 2, 3]);
    _sink.add({'a': 1, 'b': 2});
  }

  @override
  void dispose() {
    super.dispose();
    // 最後要釋放資源...
    _sink.close();
    _controller.close();
    _subscription.cancel();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(),
    );
  }
}
複製程式碼

看下控制檯的輸出:

stream1.png

果然把所有的資料都列印出來了,前面有說過,單訂閱的 stream 只有當 listen 後才會傳送資料,不試試我還是不相信的,我們把 _sink.add 放到 listen 前面去執行,再看控制檯的列印結果。居然真的是一樣的,Google 粑粑果然誠不欺我。接著試下 pauseresume 方法,看下資料如何監聽,修改程式碼

_sink = _controller.sink;
_subscription = _controller.stream.listen((data) => print('Listener: $data'));
_sink.add('A');
_subscription.pause(); // 暫停監聽
_sink.add(11);
_sink.add(11.16);
_subscription.resume(); // 恢復監聽
_sink.add([1, 2, 3]);
_sink.add({'a': 1, 'b': 2});
複製程式碼

再看控制檯的列印,你們可以先猜下是什麼結果,我猜大部分人都會覺得應該是不會有 11 和 11.16 列印出來了。然鵝事實並非這樣,列印的結果並未發生變化,也就是說,呼叫 pause 方法後,stream 被堵住了,資料不繼續傳送了。

接下來看下廣播訂閱 stream,對程式碼做下修改

StreamController _controller = StreamController.broadcast();
  Sink _sink;
  StreamSubscription _subscription;

  @override
  void initState() {
    super.initState();

    _sink = _controller.sink;

    _sink.add('A');
    _subscription = _controller.stream.listen((data) => print('Listener: $data'));

    _sink.add(11);
    _subscription.pause();
    _sink.add(11.16);
    _subscription.resume();

    _sink.add([1, 2, 3]);
    _sink.add({'a': 1, 'b': 2});
  }
// ...
}
複製程式碼

我們再看下控制檯的列印:

stream2.png

你猜對答案了嗎,這邊做下小總結:

單訂閱 Stream 只有當存在監聽的時候,才傳送資料,廣播訂閱 Stream 則不考慮這點,有資料就傳送;當監聽呼叫 pause 以後,不管哪種型別的 stream 都會停止傳送資料,當 resume 之後,把前面存著的資料都傳送出去。

sink 可以接受任何型別的資料,也可以通過泛型對傳入的資料進行限制,比如我們對 StreamController 進行型別指定 StreamController<int> _controller = StreamController.broadcast(); 因為沒有對 Sink 的型別進行限制,還是可以新增除了 int 外的型別引數,但是執行的時候就會報錯,_controller 對你傳入的引數做了型別判定,拒絕進入。

Stream 中還提供了很多 StremTransformer,用於對監聽到的資料進行處理,比如我們傳送 0~19 的 20 個資料,只接受大於 10 的前 5 個資料,那麼可以對 stream 如下操作

_subscription = _controller.stream
    .where((value) => value > 10)
    .take(5)
    .listen((data) => print('Listen: $data'));

List.generate(20, (index) => _sink.add(index));
複製程式碼

那麼列印出來的資料如下圖

stream3.png

除了 wheretake 還有很多 Transformer, 例如 mapskip 等等,小夥伴們可以自行研究。瞭解了 Stream 的基本屬性後,就可以繼續往下了~

####StreamBuilder

前面提到了 stream 通過 listen 進行監聽資料的變化,Flutter 就為我們提供了這麼個部件 StreamBuilder 專門用於監聽 stream 的變化,然後自動重新整理重建。接著來看下原始碼

const StreamBuilder({
    Key key,
    this.initialData, // 初始資料,不傳入則為 null
    Stream<T> stream,
    @required this.builder
  }) : assert(builder != null),
       super(key: key, stream: stream);

@override
AsyncSnapshot<T> initial() => AsyncSnapshot<T>.withData(ConnectionState.none, initialData);
複製程式碼

StreamBuilder 必須傳入一個 AsyncWidgetBuilder 引數,初始值 initialData 可為空, stream 用於監聽資料變化,initial 方法的呼叫在其父類 StremBuilderBase 中,接著看下 StreamBuilderBaseState 的原始碼,這裡我刪除一些不必要的原始碼,方便檢視,完整的原始碼可自行檢視

class _StreamBuilderBaseState<T, S> extends State<StreamBuilderBase<T, S>> {
  // ...
  @override
  void initState() {
    super.initState();
    _summary = widget.initial(); // 通過傳入的初始值生成預設值,如果沒有傳入則會是 null
    _subscribe(); // 註冊傳入的 stream,用於監聽變化
  }
  
  // _summary 為監聽到的資料
  @override
  Widget build(BuildContext context) => widget.build(context, _summary);

  // ...
  void _subscribe() {
    if (widget.stream != null) { 
      // stream 通過外部傳入,對資料的變化進行監聽,
      // 在不同回撥中,通過 setState 進行更新 _summary
      // 當 _summary 更新後,由於呼叫了 setState,重新呼叫 build 方法,將最新的 _summary 傳遞出去
      _subscription = widget.stream.listen((T data) {
        setState(() { 
          _summary = widget.afterData(_summary, data); 
        });
      }, onError: (Object error) {
        setState(() {
          _summary = widget.afterError(_summary, error);
        });
      }, onDone: () {
        setState(() {
          _summary = widget.afterDone(_summary);
        });
      });
      _summary = widget.afterConnected(_summary); // 
    }
  }
}
複製程式碼

在之前更新資料都需要通過 setState 進行更新,這裡瞭解完了 stream,我們就不使用 setState 更新,使用 Stream 來更新

class _StreamHomeState extends State<StreamHome> {
  // 定義一個全域性的 `StreamController`
  StreamController<int> _controller = StreamController.broadcast();
  // `sink` 用於傳入新的資料
  Sink<int> _sink;
  int _counter = 0;

  @override
  void initState() {
    super.initState();
    _sink = _controller.sink;
  }

  @override
  void dispose() {
    super.dispose();
    // 需要銷燬資源
    _sink.close();
    _controller.close();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
          child: Container(
        alignment: Alignment.center,
        child: StreamBuilder(
          builder: (_, snapshot) => Text('${snapshot.data}', style: TextStyle(fontSize: 24.0)),
          stream: _controller.stream, // stream 在 StreamBuilder 銷燬的時候會自動銷燬
          initialData: _counter,
        ),
      )),
      // 通過 `sink` 傳入新的資料,去通知 `stream` 更新到 builder 中
      floatingActionButton: FloatingActionButton(
        onPressed: () => _sink.add(_counter++),
        child: Icon(Icons.add),
      ),
    );
  }
}
複製程式碼

那麼當點選按鈕的時候,就會重新整理介面上的值,通過上面的原始碼分析,StreamBuilder 也是通過 setState 方法進行重新整理,那麼兩種方法孰優孰劣呢,當然是通過 Stream 啦,這不是廢話嗎。因為通過呼叫 setState 重新整理的話,會把整個介面都進行重構,但是通過 StreamBuilder 的話,只重新整理其 builder,這樣效率就更高了,最後看小效果吧,所謂有圖有真相嘛

stream.gif

這一步,我們摒棄了 setState 方法,那麼下一步,我們試試把 StatefulWidget 替換成 StatelessWidget 吧,而且官方也推薦使用 StatelessWidget 替換 StatefulWidget,這裡就需要提下 BLoC 模式了。

BLoC

說實話,現在 Google 下 「flutter bloc」能搜到很多文章,基本上都是通過 InheritedWidget 來實現的,例如這篇Flutter | 狀態管理探索篇——BLoC(三),但是 InheritedWidget 沒有提供 dispose 方法,那麼就會存在 StreamController 不能及時銷燬等問題,所以,參考了一篇國外的文章,Reactive Programming - Streams - BLoC 這裡通過使用 StatefulWidget 來實現,當該部件銷燬的時候,可以在其 dispose 方法中及時銷燬 StreamController,這裡我還是先當個搬運工,搬下大佬為我們實現好的基類

abstract class BaseBloc {
  void dispose(); // 該方法用於及時銷燬資源
}

class BlocProvider<T extends BaseBloc> extends StatefulWidget {
  final Widget child; // 這個 `widget` 在 stream 接收到通知的時候重新整理
  final T bloc; 
  
  BlocProvider({Key key, @required this.child, @required this.bloc}) : super(key: key);

  @override
  _BlocProviderState<T> createState() => _BlocProviderState<T>();

  // 該方法用於返回 Bloc 例項
  static T of<T extends BaseBloc>(BuildContext context) {
    final type = _typeOf<BlocProvider<T>>(); // 獲取當前 Bloc 的型別
    // 通過型別獲取相應的 Provider,再通過 Provider 獲取 bloc 例項
    BlocProvider<T> provider = context.ancestorWidgetOfExactType(type); 
    return provider.bloc; 
  }

  static Type _typeOf<T>() => T;
}

class _BlocProviderState<T> extends State<BlocProvider<BaseBloc>> {
    
  @override
  void dispose() {
    widget.bloc.dispose(); // 及時銷燬資源
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return widget.child;
  }
}
複製程式碼

接著我們對前面的例子使用 BLoC 進行修改。

首先,我們需要建立一個 Bloc 類,用於修改 count 的值


class CounterBloc extends BaseBloc {
  int _count = 0;
  int get count => _count;

  // stream
  StreamController<int> _countController = StreamController.broadcast();

  Stream<int> get countStream => _countController.stream; // 用於 StreamBuilder 的 stream

  void dispatch(int value) {
    _count = value;
    _countController.sink.add(_count); // 用於通知修改值
  }

  @override
  void dispose() {
    _countController.close(); // 登出資源
  }
}
複製程式碼

在使用 Bloc 前,需要在最上層的容器中進行註冊,也就是 MaterialApp

void main() => runApp(StreamApp());

class StreamApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 這裡對建立的 bloc 類進行註冊,如果說有多個 bloc 類的話,可以通過 child 進行巢狀註冊即可
    // 放在最頂層,可以全域性呼叫,當 App 關閉後,銷燬所有的 Bloc 資源,
    // 也可以在路由跳轉的時候進行註冊,至於在哪裡註冊,完全看需求
    // 例如實現主題色的切換,則需要在全域性定義,當切換主題色的時候全域性切換
    // 又比如只有某個或者某幾個特殊介面呼叫,那麼完全可以通過在路由跳轉的時候註冊
    return BlocProvider(  
        child: MaterialApp(
          debugShowCheckedModeBanner: false,
          home: StreamHome(),
        ),
        bloc: CounterBloc());
  }
}

class StreamHome extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 獲取註冊的 bloc,必須先註冊,再去查詢
    final CounterBloc _bloc = BlocProvider.of<CounterBloc>(context); 
    return Scaffold(
      body: SafeArea(
          child: Container(
        alignment: Alignment.center,
        child: StreamBuilder(
          initialData: _bloc.count,
          stream: _bloc.countStream,
          builder: (_, snapshot) => Text('${snapshot.data}', style: TextStyle(fontSize: 20.0)),
        ),
      )),
      floatingActionButton:
          // 通過 bloc 中的 dispatch 方法進行值的修改,通知 stream 重新整理介面
          FloatingActionButton(onPressed: () => 
                               _bloc.dispatch(_bloc.count + 1), child: Icon(Icons.add)),
    );
  }
}
複製程式碼

重新執行後,檢視效果還是一樣的。所以我們成功的對 StatefulWidget 進行了替換

再繼續講之前,先總結下 Bloc

1. 成功的把頁面和邏輯分離開了,頁面只展示資料,邏輯通過 BLoC 進行處理

2. 減少了 setState 方法的使用,提高了效能

3. 實現了狀態管理

RxDart

因為上面的參考文章中提到了 RxDart,個人覺得有必要了解下,當然目前也有很多文章介紹 RxDart,所以我就講下和 BLoC 有點關係的部分吧。RxDart 需要通過引入外掛的方式引入(rxdart: ^0.21.0)

如果需要檢視詳細的內容,我這裡提供幾篇文章連結

RxDart 文件

RxDart: Magical transformations of Streams

其實 RxDart 就是對 Stream 的進一步分裝,RxDart 提供了三種 Subject,其功能類似 Stream 中的單訂閱 stream 和 廣播 stream。

  1. PublishSubject

    /// PublishSubject is, by default, a broadcast (aka hot) controller, in order
    /// to fulfill the Rx Subject contract. This means the Subject's `stream` can
    /// be listened to multiple times.
    複製程式碼

    通過註釋可以發現 PuslishSubject 不可被多次訂閱,儘管實現是通過 StreamController<T>.broadcast 方式實現,其實三種都是通過 broadcast 方式實現的,所以實現的功能就是類似 Single-subscription Stream 的功能。

  2. BehaviorSubject

    /// BehaviorSubject is, by default, a broadcast (aka hot) controller, in order
    /// to fulfill the Rx Subject contract. This means the Subject's `stream` can
    /// be listened to multiple times.
    複製程式碼

    BehaviorSubject 可以被多次訂閱,那麼這個就是實現了 Broadcast Stream 功能。

  3. ReplaySubject

    /// ReplaySubject is, by default, a broadcast (aka hot) controller, in order
    /// to fulfill the Rx Subject contract. This means the Subject's `stream` can
    /// be listened to multiple times.
    複製程式碼

    ReplaySubject 其實也是實現 Broadcast Stream 功能,那麼它和 BehaviorSubject 的區別在哪呢,別急,等我慢慢講。

    /// As items are added to the subject, the ReplaySubject will store them.
    /// When the stream is listened to, those recorded items will be emitted to
    /// the listener.
    複製程式碼

    當有資料新增了,但是還沒有監聽的時候,它會將資料儲存下來,等到有監聽了,再傳送出去,也就是說,ReplaySubject 實現了 Brodacast Stream 的多訂閱功能,同時也實現了 Single-subscription Stream 的儲存資料的功能,每次新增了新的監聽,都能夠獲取到全部的資料。當然,這還不是它的全部功能,它還可以設定最大的監聽數量,會只監聽最新的幾個資料,在註釋中,提供了這麼兩個例子,可以看下

    /// ### Example 
    ///
    ///     final subject = new ReplaySubject<int>();
    ///
    ///     subject.add(1);
    ///     subject.add(2);
    ///     subject.add(3);
    ///
    ///     subject.stream.listen(print); // prints 1, 2, 3
    ///     subject.stream.listen(print); // prints 1, 2, 3
    ///     subject.stream.listen(print); // prints 1, 2, 3
    ///
    /// ### Example with maxSize
    ///
    ///     final subject = new ReplaySubject<int>(maxSize: 2); // 實現監聽數量限制
    ///
    ///     subject.add(1);
    ///     subject.add(2);
    ///     subject.add(3);
    ///
    ///     subject.stream.listen(print); // prints 2, 3
    ///     subject.stream.listen(print); // prints 2, 3
    ///     subject.stream.listen(print); // prints 2, 3
    複製程式碼

那麼我們可以使用 RxDart 對前面使用 Stream 實現的例子進行替換,最簡單的其實只需要使用 BehaviorSubject 替換 StreamController.broadcast() 就可以了,別的都不需要變化。但是 RxDart 有自己的變數,還是按照 RxDart 的方式來

// 繼承自 StreamController,所以 StreamController 擁有的屬性都有
BehaviorSubject<int> _countController = BehaviorSubject();
//  StreamController<int> _countController = StreamController.broadcast();

// 繼承自 Stream,所以這裡直接用之前 stream 的寫法也沒問題,但是這樣就有點不 RxDart 了
Observable<int> get countStream => Observable(_countController.stream);
//  Stream<int> get countStream => _countController.stream;

void dispatch(int value) {
  _count = value;
  // 直接提供了 add 方法,不需要通過 sink 來新增
  _countController.add(_count);
//    _countController.sink.add(_count);
}
複製程式碼

再次執行還是能過實現相同的效果。如果說要在 RxDartStream 兩種實現方式中選擇一種,個人更偏向於 RxDart,因為它對 Stream 進行了進一步的封裝,提供了更多更方便的資料轉換方法,而且鏈式的寫法真的很舒服,用過了就停不下來,具體的方法介紹可以參考上面提供的連結。

Provide

說實話自己封裝 BLoC 來實現分離邏輯和介面,相對還是有點難度的,這邊可以通過第三方來實現,這邊推薦 Google 粑粑的庫,flutter_provide,看下官方對關鍵部件和靜態方法的介紹

  • Provide<T> - Widget used to obtain values from a ProviderNode higher up in the widget tree and rebuild on change. The Provide<T>widget should only be used with Streams or Listenables. Equivalent to ScopedModelDescendant in ScopedModel
  • Provide.value<T> - Static method used to get a value from a ProviderNode using the BuildContext. This will not rebuild on change. Similar to manually writing a static .of() method for an InheritedWidget.
  • Provide.stream<T> - Static method used to get a Stream from a ProviderNode. Only works if either T is listenable, or if the Providercomes from a Stream.
  • Provider<T> - A class that returns a typed value on demand. Stored in a ProviderNode to allow retrieval using Provide.
  • ProviderNode - The equivalent of the ScopedModel widget. Contains Providers which can be found as an InheritedWidget.

Provide 這個部件主要用於從上層的 ProvideNode 中獲取值,當變化的時候重新整理重建,只能同 StreamListenable 一同使用,類似於 ScopeMode 中的 ScopedModelDescendant(這個部件放在需要狀態管理的部件的上層,例如有個 Text 需要修改狀態,那麼就需要在外層提供一個 Provide 部件,通過內部 builder 引數返回 Text 部件)

Provide.value 是個靜態方法,用於從 ProvideNode 獲取值,但是當接收的值改變的時候不會重建。類似於 InheritedWidget 的靜態方法 of(這個方法用於獲取指定型別的 provide,每個 provide 都需要提供一個資料類,該類 with ChangeNotifier,當資料變化的時候通過 notifyListeners 通知 provide 變化,進行重新整理重建)

Provide.stream 是個靜態方法,用於從 ProvideNode 獲取一個 stream,僅在 T 可被監聽,或者 Provide 來自 stream 的情況下有效。(這個通常結合 StreamBuilder 使用,StreamBuilder 在上面已經提到,就不多說了)

Provider 按需要的型別返回相關值的類,儲存在 ProviderNode 中方便 Provide 進行檢索。(這個類主要是將我們自己建立的資料類通過 function 等方法轉換成 Provider,並在 Providers 中進行註冊)

ProvideNode 類似於 ScopedModel 的一個部件,包含所有能被查詢的 Providers(這個需要放在頂層,方便下面的容器進行查詢 provider,重新整理相應的部件,一般放在 MaterialApp 上層)

這邊再補充一個個人覺得關鍵的類 Providers,這個類主要用於儲存定義的 Provider,主要是在建立 MaterialApp 的時候將需要用到的 Provider 通過 provide 方法新增進去儲存起來,然後在 ProvideNode 中註冊所有的 provider 方便下層容器獲取值,並呼叫。

說那麼多,還不如直接看個例子直接,程式碼來了~,首先需要建立一個類似 BLoC 中監聽資料變化的 counter_bloc 類的資料管理類,我們這邊定義為 count_provider 需要混入 ChangeNotifier

class CountProvider with ChangeNotifier {
  int _value = 0; // 儲存的資料,也是我們需要管理的狀態值

  int get value => _value; // 獲取狀態值

  void changeValue(int value) {
    _value = value;
    notifyListeners(); // 當狀態值發生變化的時候,通過該方法重新整理重建部件
  }
}
複製程式碼

然後需要將定義的類註冊到全域性的 Providers

void main() {
  final providers = Providers()
    // 將我們建立的資料管理類,通過 Provider.function 方法轉換成 Provider,
    // 然後新增到 Providers 中
    ..provide(Provider.function((_) => CountProvider()));
  // 在 App 上層,通過包裹一層 ProvideNode,並將我們生成的 Providers 例項
  // 註冊到 ProvideNode 中去,這樣整個 App 都可以通過 Provide.value 查詢相關的 Provider
  // 找到 Provider 後就可以找到我們的資料管理類
  runApp(ProviderNode(child: StreamApp(), providers: providers));
}
複製程式碼

接著就是替換我們的介面實現了,前面通過 BLoC 實現,這裡替換成 Provide 來實現

class StreamHome extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
          child: Container(
        alignment: Alignment.center,
        // 通過指定型別,獲取特定的 Provide,這個 Provide 會返回我們的資料管理類 provider
        // 通過內部定義的方法,獲取到需要展示的值
        child: Provide<CountProvider>(builder: (_, widget, provider) => Text('${provider.value}')),
      )),
      floatingActionButton: FloatingActionButton(
          onPressed: () =>
          	  // 通過 value 方法獲取到我們的資料管理類 provider,
          	  // 通過呼叫改變值的方法,修改內部的值,並通知介面重新整理重建
              Provide.value<CountProvider>(context).changeValue(
                  Provide.value<CountProvider>(context).value + 1),
          child: Icon(Icons.add))
    );
  }
}
複製程式碼

本文程式碼檢視 bloc 包名下的所有檔案,需要單獨執行 stream_main.dart 檔案

最後執行後還是一樣的效果,也摒棄了 StatefulWidget 部件和 SetState 方法,實現了邏輯和介面分離。但是 Provide 最終還是通過 InheritedWidget 來實現,當然在資源方面 Google 的大佬們做了一些相關的處理,至於如何處理,這邊就不多說了。目前 provide 的這個庫還存在一點爭議的地方,具體檢視 issue#3,但是目前來看並沒有太大的影響。當然你不放心的話,可以使用 Scoped_model 或者上面的 Bloc 模式,Google 在文件也有相關的註明

If you must choose a package today, it's safer to go with package:scoped_model than with this package.

這篇概念性的比較多,但是等理解了以後,對於以後的開發還是非常有利的。

最後程式碼的地址還是要的:

  1. 文章中涉及的程式碼:demos

  2. 基於郭神 cool weather 介面的一個專案,實現 BLoC 模式,實現狀態管理:flutter_weather

  3. 一個課程(當時買了想看下程式碼規範的,程式碼更新會比較慢,雖然是跟著課上的一些寫程式碼,但是還是做了自己的修改,很多地方看著不舒服,然後就改成自己的實現方式了):flutter_shop

如果對你有幫助的話,記得給個 Star,先謝過,你的認可就是支援我繼續寫下去的動力~

相關文章