CNoder 應用遷移記 | 掘金技術徵文

AliChen發表於2019-02-22

前言

之前在學會 React-Native 後寫了一個 cnodejs社群的客戶端 CNodeRN,前陣子瞭解了下 flutter, 感覺是移動應用開發的未來趨勢,便有了遷移至 flutter 技術棧的想法, 然後就有了 CNoder 這個專案, 也算是對數週 flutter 的一個學習實踐吧

安裝和初始化

跟著官方的安裝說明一步一步往下走,還是挺順利的,唯一不同的就是增加了映象設定這一步, 開啟 ~/.zhsrc, 末尾增加

 ## flutter
125 export PUB_HOSTED_URL=https://pub.flutter-io.cn
126 export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
127 export PATH=$HOME/flutter/bin:$PATH
複製程式碼

然後執行 flutter doctor 檢查環境是否正常,一切順利的話就可以初始化專案了,我使用的編輯器是 vscode, 通過命令視窗執行命令 Flutter: New Project 即可

專案目錄結構

原始碼都位於 lib 目錄下

|-- config/
    |-- api.dart // http api 呼叫介面地址配置
|-- common/
    |-- helper.dart // 工具函式
|-- route/
    |-- handler.dart // 路由配置檔案
|-- store/
    |-- action/  // redux action 目錄
    |-- epic/   // redux_epic 配置目錄
    |-- reducer/ // redux reducer 目錄
    |-- model/ // 模型目錄
    |-- view_model/ // store 對映模型目錄
    |-- root_state.dart // 全域性 state
    |-- index.dart // store 初始入口
|-- container/  // 連線 store 的容器目錄
|-- widget/ // 檢視 widget 目錄
main.dart // 入口檔案
app.dart // 入口widget
複製程式碼

功能模組

  • 入口檔案: main.dart, 邏輯很簡單就不描述了
  • 入口widget: app.dart檔案
class App extends StatelessWidget {
  // 初始化路由外掛
  final Router router = new Router();

  App() {
    // 從持久化儲存里載入資料狀態,這裡用來儲存使用者的身份令牌資訊
    persistor.load(store);
    // 404處理
    router.notFoundHandler = notFoundHandler;
    // 應用路由配置
    handlers.forEach((String path,Handler handler) {
      router.define(path, handler: handler);
    });
  }

  @override
    Widget build(BuildContext context) {
      final app = new MaterialApp(
        title: `CNoder`,
        // 禁用右上角的 debug 標誌
        debugShowCheckedModeBanner: false,
        theme: new ThemeData(
          primarySwatch: Colors.lightGreen,
          // 定義全域性圖示主題
          iconTheme: new IconThemeData(
            color: Color(0xFF666666)
          ),
          // 定義全域性文字主題
          textTheme: new TextTheme(
            body1: new TextStyle(color: Color(0xFF333333), fontSize: 14.0)
          )
        ),
        // 將 應用的路由對映至 fluro 的路由表裡面去
        onGenerateRoute: router.generator
      );

      return  new StoreProvider<RootState>(store: store, child: app);
    }
}
複製程式碼

這裡有個坑,如果按照 fluro 提供的文件將應用路由對映至fluro的路由表,使用的方式是 onGenerateRoute: router.generator, 但是這樣的話在路由跳轉時就無法指定過渡動效了,因此需要改成這樣

onGenerateRoute: (RouteSettings routeSettings) {
  // 這個方法可以在 router.generator 原始碼裡找到,返回匹配的路由
  RouteMatch match = this.router.matchRoute(null, routeSettings.name, routeSettings: routeSettings, transitionType: TransitionType.inFromRight);
  return match.route;
},
複製程式碼

使用 StoreProvider 容器包裹整個應用入口widget,這樣才能在子節點的widget上使用StoreConnector連線store來獲取資料狀態和派發action

  • 接下來應用會進入路由機制,下面是部分路由配置資訊
import "dart:core";
import "package:fluro/fluro.dart";
import "package:flutter/material.dart";
import "package:cnoder/container/index.dart";

Map<String, Handler> handlers = {
  `/`: new Handler(
      handlerFunc: (BuildContext context, Map<String, dynamic> params) {
    return new IndexContainer();
  }),
  ...
};
複製程式碼

container/index.dart 類似於 react 裡面的 HOC,將 store 連線至子widget

import "package:flutter/material.dart";
import "package:redux/redux.dart";
import "package:flutter_redux/flutter_redux.dart";
import "../store/root_state.dart";
import "../store/view_model/index.dart";
import "../widget/index.dart";

class IndexContainer extends StatelessWidget{
  @override
    Widget build(BuildContext context) {
      return new StoreConnector<RootState, IndexViewModel>(
        converter: (Store<RootState> store) => IndexViewModel.fromStore(store),
        builder: (BuildContext context, IndexViewModel vm) {
          return new IndexScene(vm: vm);
        },
      );
    }
}
複製程式碼

converter 引數相當於在使用 react+redux 技術棧裡面的使用 connect 函式包裹元件時的 mapAction 和 mapState 引數,將返回值作為 builder 引數對應的回撥函式第二個入參 vm.

  • widget/index.dart 為首頁的檢視widget,通過底部的標籤欄切換四個容器widget的顯示
class IndexState extends State<IndexScene> {
  // 根據登陸狀態切換顯示
  List _renderScenes(bool isLogined) {
    final bool isLogined = widget.vm.auth["isLogined"];
    return <Widget>[
      new TopicsContainer(vm: widget.vm),
      isLogined ? new CollectContainer(vm: widget.vm) : new LoginScene(),
      isLogined ? new MessageContainer(vm: widget.vm,) : new LoginScene(),
      isLogined ? new MeContainer(vm: widget.vm,) : new LoginScene()
    ];
  }

  @override
    Widget build(BuildContext context) {
      final bool isLogined = widget.vm.auth["isLogined"];
      final List scenes = _renderScenes(isLogined);
      final int tabIndex = widget.vm.tabIndex;
      final Function setTab = widget.vm.selectTab;
      
      final currentScene = scenes[0];
      // 這裡保證了初始化widget的服務呼叫
      if (currentScene is InitializeContainer) {
        if (currentScene.getInitialized() == false) {
          currentScene.initialize();
          currentScene.setInitialized();
        }
      }

      return new Scaffold(
        bottomNavigationBar: new CupertinoTabBar(
          activeColor: Colors.green,
          backgroundColor: const Color(0xFFF7F7F7),
          currentIndex: tabIndex,
          onTap: (int i) {
            final currentScene = scenes[i];
            if (isLogined) {
              //  這裡保證了widget的服務呼叫在切換時只進行一次
              if (currentScene is InitializeContainer) {
                if (currentScene.getInitialized() == false) {
                  currentScene.initialize();
                  currentScene.setInitialized();
                }
              }
            }
            setTab(i);
          },
          items: <BottomNavigationBarItem>[
            new BottomNavigationBarItem(
              icon: new Icon(Icons.home),
              title: new Text(`主題`),
            ),
            new BottomNavigationBarItem(
              icon: new Icon(Icons.favorite),
              title: new Text(`收藏`)
            ),
            new BottomNavigationBarItem(
              icon: new Icon(Icons.message),
              title: new Text(`訊息`)
            ),
            new BottomNavigationBarItem(
              icon: new Icon(Icons.person),
              title: new Text(`我的`)
            )
          ],
        ),
        // 使用層疊widget來包裹檢視,同一時間僅一個檢視widget可見
        body: new IndexedStack(
          children: scenes,
          index: tabIndex,
        )
      );
    }
}
複製程式碼

很多同學會有疑問,tabIndex 這個應該只是首頁widget的內部資料狀態,為何要放到 redux 裡去維護?因為我們在子widget裡面會去切換頁籤的選中狀態,比如登陸完成以後切換至`我的`這個頁籤

  • 主題檢視容器widget,在容器元件裡面觸發服務呼叫獲取主題資料
// 初始化標誌位
bool initialized = false;

class TopicsContainer extends StatelessWidget implements InitializeContainer{
  final IndexViewModel vm;

  TopicsContainer({Key key, @required this.vm}):super(key: key);

  // 標記已初始化,防止在首頁頁籤切換時重複呼叫
  void setInitialized() {
    initialized = true;
  }

  // 獲取初始化狀態
  bool getInitialized() {
    return initialized;
  }

  // 初始化的操作是呼叫 redux action 獲取主題資料
  void initialize() {
    vm.fetchTopics();
  }

  @override
    Widget build(BuildContext context) {
      return new StoreConnector<RootState, TopicsViewModel>(
        converter: (Store<RootState> store) => TopicsViewModel.fromStore(store),
        builder: (BuildContext context, TopicsViewModel vm) {
          return new TopicsScene(vm: vm);
        },
      );
    }
}
複製程式碼
  • 主題檢視widget,頂部四個頁籤用來切換顯示四個主題分類
class TopicsState extends State<TopicsScene> with TickerProviderStateMixin{
  @override
  void initState() {
    super.initState();
    final topicsOfCategory = widget.vm.topicsOfCategory;

    _tabs = <Tab>[];
    // 初始化頂部頁籤欄
    topicsOfCategory.forEach((k, v) {
      _tabs.add(new Tab(
        text: v["label"]
      ));
    });
    // 初始化 TabBar 和 TabBarView 的控制器
    _tabController  = new TabController(
      length: _tabs.length,
      vsync: this // _tabController 作為屬性的類必須通過 TickerProviderStateMixin 擴充套件
    );
    
    // 頁籤切換事件監聽
    _onTabChange = () {
        ...
    };
    
    // 給頁籤控制器增加一個事件監聽器,監聽頁籤切換事件
    _tabController.addListener(_onTabChange);
  }
  
  @override
  void dispose() {
    super.dispose();
    // 類銷燬之前移除頁籤控制器的事件監聽
    _tabController.removeListener(_onTabChange);
    // 銷燬頁籤控制器
    _tabController.dispose();
  }
  
  @override
    Widget build(BuildContext context) {
      bool isLoading = widget.vm.isLoading;
      Map topicsOfCategory = widget.vm.topicsOfCategory;
      FetchTopics fetchTopics = widget.vm.fetchTopics;
      ResetTopics resetTopics = widget.vm.resetTopics;
      
      ...
      
      // 迴圈顯示分類下的主題列表
      List<Widget> _renderTabView() {
        final _tabViews = <Widget>[];
        topicsOfCategory.forEach((k, category) {
          bool isFetched = topicsOfCategory[k]["isFetched"];
          // 如果該分類下的主題列表未初始化先渲染一個載入指示
          _tabViews.add(!isFetched ? _renderLoading(context) :
          // 使用 pull_to_refresh 包提供的下拉重新整理和上來載入功能
          new SmartRefresher(
            enablePullDown: true,
            enablePullUp: true,
            onRefresh: _onRefresh(k),
            controller: _controller,
            child: new ListView.builder(
              physics: const NeverScrollableScrollPhysics(),
              shrinkWrap: true,
              itemCount: topicsOfCategory[k]["list"].length,
              itemBuilder: (BuildContext context, int i) => _renderRow(context, topicsOfCategory[k]["list"][i]),
            ),
          ));
        });
        return _tabViews;
      }
      
      // 使用 ListTile 渲染列表中的每一行
      Widget _renderRow(BuildContext context, Topic topic) {
      ListTile title = new ListTile(
        leading: new SizedBox(
          width: 30.0,
          height: 30.0,
          // 使用 cached_network_image 提供支援快取和佔點陣圖的功能顯示頭像
          child: new CachedNetworkImage(
            imageUrl: topic.authorAvatar.startsWith(`//`) ? `http:${topic.authorAvatar}` : topic.authorAvatar,
            placeholder: new Image.asset(`asset/image/cnoder_avatar.png`),
            errorWidget: new Icon(Icons.error),
          )
        ),
        title: new Text(topic.authorName),
        subtitle: new Row(
          children: <Widget>[
            new Text(topic.lastReplyAt)
          ],
        ),
        trailing: new Text(`${topic.replyCount}/${topic.visitCount}`),
      );
      return new InkWell(
        // 點選後跳轉至主題詳情
        onTap: () => Navigator.of(context).pushNamed(`/topic/${topic.id}`),
        child: new Column(
          children: <Widget>[
            title,
            new Container(
              padding: const EdgeInsets.all(10.0),
              alignment: Alignment.centerLeft,
              child: new Text(topic.title),
            )
          ],
        ),
      );
    }
    
      return new Scaffold(
        appBar: new AppBar(
          brightness: Brightness.dark,
          elevation: 0.0,
          titleSpacing: 0.0,
          bottom: null,
          // 頂部顯示頁籤欄
          title: new Align(
            alignment: Alignment.bottomCenter,
            child: new TabBar(
              labelColor: Colors.white,
              tabs: _tabs,
              controller: _tabController,
            )
          )
        ),
        // 主體區域顯示頁籤內容
        body: new TabBarView(
          controller: _tabController,
          children: _renderTabView(),
        )
      );
    }
}
複製程式碼

資料狀態

  • store/view_model/topics.dart 檢視對映模型定義

通過檢視對映模型將 store 裡面的 state 和 action 傳遞給檢視widget,
在上面的主題容器widget裡面我們通過 vm.fetchTopics 方法獲取主題資料, 這個方法是在 TopicsViewModel 這個
store 對映模型裡定義的

class TopicsViewModel {
  final Map topicsOfCategory;
  final bool isLoading;
  final FetchTopics fetchTopics;
  final ResetTopics resetTopics;

  TopicsViewModel({
    @required this.topicsOfCategory, 
    @required this.isLoading, 
    @required this.fetchTopics, 
    @required this.resetTopics
  });

  static TopicsViewModel fromStore(Store<RootState> store) {
    return new TopicsViewModel(
      // 對映分類主題列表
      topicsOfCategory: store.state.topicsOfCategory,
      // 對映載入狀態
      isLoading: store.state.isLoading,
      // 獲取主題資料 action 的包裝方法
      fetchTopics: ({int currentPage = 1, String category = ``, Function afterFetched = _noop}) {
        // 通過 isLoading 資料狀態的變更來切換widget的載入指示器的顯示
        store.dispatch(new ToggleLoading(true));
        // 觸發獲取主題資料的action,將當前頁,分類名,以及呼叫成功的回撥函式傳遞給action
        store.dispatch(new RequestTopics(currentPage: currentPage, category: category, afterFetched: afterFetched));
      },
      // 重新整理主題資料的包裝方法
      resetTopics: ({@required String category, @required Function afterFetched}) {
        store.dispatch(new RequestTopics(currentPage: 1, category: category, afterFetched: afterFetched));
      }
    );
  }
}
複製程式碼

這裡增加了一個呼叫成功的回撥函式給 action,是因為需要在 http 服務呼叫完成以後控制主題檢視widget裡面 SmartRefresher 這個widget 狀態的切換(重置載入指示等等)

final _onRefresh = (String category) {
    return (bool up) {
      // 如果是上拉載入更多
      if (!up) {
        if (isLoading) {
          _controller.sendBack(false, RefreshStatus.idle);
          return;
        }
        fetchTopics(
          currentPage: topicsOfCategory[category]["currentPage"] + 1,
          category: category,
          afterFetched: () {
            // 上拉載入更多指示器復位
            _controller.sendBack(false, RefreshStatus.idle);
          }
        );
      // 如果是下拉重新整理
      } else {
        resetTopics(
          category: category,
          afterFetched: () {
            // 下拉重新整理指示器復位
            _controller.sendBack(true, RefreshStatus.completed);
          }
        );
      }
    };
  };
複製程式碼
  • store/action/topic.dart action 定義

在 flutter 中以類的方式來定義 action 的,這一點與我們在 react 中使用 redux 有點不同

// 傳送主題列表請求的 action
class RequestTopics {
  // 當前頁
  final int currentPage;
  // 分類
  final String category;
  // 請求完成的回撥
  final VoidCallback afterFetched;

  RequestTopics({this.currentPage = 1, this.category = "", @required this.afterFetched});
}

// 響應主題列表請求的 action
class ResponseTopics {
  final List<Topic> topics;
  final int currentPage;
  final String category;

  ResponseTopics(this.currentPage, this.category, this.topics);

  ResponseTopics.failed() : this(1, "", []);
}
複製程式碼
  • epic 定義,redux epic 可以看成是 action 的一個排程器,雖然 flutter 裡的redux 也有 redux_thunk 中介軟體,但是 epic 這種基於流的排程中介軟體使得業務邏輯更加優雅
Stream<dynamic> fetchTopicsEpic(
    Stream<dynamic> actions, EpicStore<RootState> store) {
  return new Observable(actions)
      // 過濾特定請求
      .ofType(new TypeToken<RequestTopics>())
      .flatMap((action) {
        // 通過非同步生成器來構建一個流
        return new Observable(() async* {
          try {
            // 傳送獲取主題列表的 http 請求
            final ret = await http.get("${apis[`topics`]}?page=${action.currentPage}&limit=6&tab=${action.category}&mdrender=false");
            Map<String, dynamic> result = json.decode(ret.body);
            List<Topic> topics = [];
            result[`data`].forEach((v) {
              topics.add(new Topic.fromJson(v));
            });
            // 觸發請求完成的回撥,就是我們上面提到的 SmartRefresher widget 的復位
            action.afterFetched();
            yield new ResponseTopics(action.currentPage, action.category, topics);
          } catch(err) {
            print(err);
            yield new ResponseTopicsFailed(err);
          }
          // 重新整理資料狀態復位
          yield new ToggleLoading(false);
        } ());
  });
}
複製程式碼

在接收到請求響應後,通過 Topic.fromJson 這個指定類構造器來建立主題列表,這個方法定義在 store/model/topic.dart裡面

Topic.fromJson(final Map map):
    this.id = map["id"],
    this.authorName = map["author"]["loginname"],
    this.authorAvatar = map["author"]["avatar_url"],
    this.title = map["title"],
    this.tag = map["tab"],
    this.content = map["content"],
    this.createdAt = fromNow(map["create_at"]),
    this.lastReplyAt = fromNow(map["last_reply_at"]),
    this.replyCount = map["reply_count"],
    this.visitCount = map["visit_count"],
    this.top = map["top"],
    this.isCollect = map["is_collect"],
    this.replies = formatedReplies(map[`replies`]);
複製程式碼
  • store/reducer/topic.dart, 通過主題列表的 reducer 來變更 store 裡面的資料狀態
final Reducer<Map> topicsReducer = combineReducers([
  // 通過指定 action 型別來拆分
  new TypedReducer<Map, ClearTopic>(_clearTopic),
  new TypedReducer<Map, RequestTopics>(_requestTopics),
  new TypedReducer<Map, ResponseTopics>(_responseTopics)
]);

// 清空主題列表
Map _clearTopic(Map state, ClearTopic action) {
  return {};
}

Map _requestTopics(Map state, RequestTopics action) {
  Map topicsOfTopics = {};
  state.forEach((k, v) {
    final _v = new Map.from(v);
    if (action.category == k) {
      // 通過 isFetched 標誌位來防止分類頁面切換時重複請求
      _v["isFetched"] = false;
    }
    topicsOfTopics[k] = _v;
  });
  return topicsOfTopics;
}

Map _responseTopics(Map state, ResponseTopics action) {
  Map topicsOfCategory = {};
  state.forEach((k, v) {
    Map _v = {};
    _v.addAll(v);
    if (k == action.category) {
      List _list = [];
      // 上拉載入更多時
      if (_v[`currentPage`] < action.currentPage) {
        _list.addAll(_v["list"]);
        _list.addAll(action.topics);
      } 
      // 下拉重新整理時
      if (action.currentPage == 1) {
        _list.addAll(action.topics);
      }
      // 通過 isFetched 標誌位來防止分類頁面切換時重複請求
      _v["isFetched"] = true;
      _v["list"] = _list;
      _v["currentPage"] = action.currentPage;
    }
    topicsOfCategory[k] = _v;
  });
  return topicsOfCategory;
}
複製程式碼

然後在 store/reducer/root.dart 的 rootReducer 裡進行合併

RootState rootReducer(RootState state, action) {
  // 處理從持久化儲存里載入資料狀態
  if (action is PersistLoadedAction<RootState>) {
    return action.state ?? state;
  }
  // 將 state 裡的資料狀態對應到子 reducer
  return new RootState(
    tabIndex: tabReducer(state.tabIndex, action),
    auth:  loginReducer(state.auth, action),
    isLoading: loadingReducer(state.isLoading, action),
    topicsOfCategory: topicsReducer(state.topicsOfCategory, action),
    topic: topicReducer(state.topic, action),
    me: meReducer(state.me, action),
    collects: collectsReducer(state.collects, action),
    messages: messagesReducer(state.messages, action)
  );
}
複製程式碼
  • store/index.dart store 的初始化入口,在我們上面的入口widget裡面使用 StoreProvider 容器包裹的時候傳遞
// 合併 epic 獲得根 epic 提供給 epic 中介軟體呼叫
final epic = combineEpics([
  doLoginEpic, 
  fetchTopicsEpic, fetchTopicEpic, 
  fetchMeEpic,
  fetchCollectsEpic,
  fetchMessagesEpic,
  fetchMessageCountEpic,
  markAllAsReadEpic,
  markAsReadEpic,
  createReplyEpic,
  saveTopicEpic,
  createTopicEpic,
  toggleCollectEpic,
  likeReplyEpic,
]);

// 初始化持久化中介軟體儲存容器
final persistor = Persistor<RootState>(
  storage: FlutterStorage(`cnoder`),
  decoder: RootState.fromJson,
  debug: true
);

// 初始化 store
final store = new Store<RootState>(rootReducer,
  initialState: new RootState(), middleware: [
    new LoggingMiddleware.printer(), 
    new EpicMiddleware(epic),
    persistor.createMiddleware()
]);
複製程式碼

這裡有個小坑,持久化儲存中介軟體 redux_persist 的文件上載入中介軟體的方式為

var store = new Store<AppState>(
  reducer,
  initialState: new AppState(),
  middleware: [persistor.createMiddleware()],
);
複製程式碼

但是這樣處理的話,在每個業務 action 觸發的時候,都會觸發持久化的操作,而這在很多場景下是不必要的,比如在我們的應用中只需要儲存的使用者身份令牌,所以只需要在觸發登陸和登出 action 的時候執行持久化的操作,因此載入中介軟體的方式需要做如下改動

void persistMiddleware(Store store, dynamic action, NextDispatcher next) {
  next(action);
  // 僅處理登陸和登出操作
  if (action is FinishLogin || action is Logout) {
    try {
      persistor.save(store);
    } catch (_) {}
  }
}

// 初始化 store
final store = new Store<RootState>(rootReducer,
  initialState: new RootState(), middleware: [
    new LoggingMiddleware.printer(), 
    new EpicMiddleware(epic),
    persistMiddleware
]);
複製程式碼

更多

應用的檢視層和資料狀態處理還是跟使用 React-Native 開發中使用 redux 技術棧的方式差不多,雖然整體目錄結構有點繁瑣,但是業務邏輯清晰明瞭,在後續功能擴充套件和維護的時候還是帶來不少的方便,唯一遺憾的是因為 flutter 系統架構的問題,還沒有一個針對 flutter 的 redux devtools,這一點還是蠻影響開發效率的

完整的專案原始碼請關注github倉庫: cnoder,歡迎 star 和 PR,對 flutter 理解的不深,還望各位對本文中的不足之處批評指正

從 0 到 1:我的 Flutter 技術實踐 | 掘金技術徵文,徵文活動正在進行中

相關文章