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

前言

以前在學會 React-Native 後寫了一個 cnodejs社區的客戶端 CNodeRN,前陣子瞭解了下 flutter, 感受是移動應用開發的將來趨勢,便有了遷移至 flutter 技術棧的想法, 而後就有了 CNoder 這個項目, 也算是對數週 flutter 的一個學習實踐吧node

安裝和初始化

跟着官方的安裝說明一步一步往下走,仍是挺順利的,惟一不一樣的就是增長了鏡像設置這一步, 打開 ~/.zhsrc, 末尾增長react

## 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 便可git

項目目錄結構

源碼都位於 lib 目錄下github

|-- 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, 可是這樣的話在路由跳轉時就沒法指定過渡動效了,所以須要改爲這樣macos

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來獲取數據狀態和派發actionjson

  • 接下來應用會進入路由機制,下面是部分路由配置信息
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 鏈接至子widgetredux

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.api

  • 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 映射模型裏定義的bash

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 技術實踐 | 掘金技術徵文,徵文活動正在進行中

相關文章
相關標籤/搜索