Flutter 三探

PJHubs_xx發表於2019-01-17

原文地址:PJ 的 iOS 開發之路

歷時一個星期對 Flutter 一期調研在這篇文章中就告一段落了,這篇文章中繼續完善上篇文章中利用豆瓣電影 Top250 公開 API demo。

前言

在這兩三天的繼續完善 demo 的時間中,首先是對 Flutter 在基本 UI 視覺方面的實現表示讚賞,有些地方的 UI 佈局的程式碼編寫習慣了 Flutter 的思維後,會有一個非常快速的反應。下面是具體 demo 具體的完成圖:

首頁.png

下拉重新整理.png

詳情.png

整體 demo 做完後,全程都是在使用 meizu 15 這臺開發機進行除錯,在 flutter 的 IDE 選擇上一直都在使用 Android Studio,在斷點除錯、檢視渲染節點、效能對比等活動上都非常方便的解決了,依然強推!但整體沒有遵循 Flutter 官方推薦的 BloC 設計模式,還是採用“設計模式之王”的 MVC,同樣是考慮到了後續在對比其它跨段方案時儘可能的保證一致性。

資料來源

豆瓣電影詳情 API 同樣不需要做驗證,傳入對應電影的 id 即可,但會限制同一 IP 在一定間隔時間內的訪問次數,如果在一定間隔時間內容訪問 API 的速度過於頻繁,則會拒絕服務,不過得益於 Flutter 的 hot reload 技術,可以不需要每次都重新拉去資料。該詳情 API 多了一些更具體的資料,但依然沒有達到豆瓣 App 本身那般豐富。

《神祕巨星》電影詳情資料.png

涉及 Flutter 知識點

  • 下拉重新整理;
  • 上拉載入;
  • 利用 GestureDetector Widget 進行頁面跳轉(動態路由方式);
  • 利用 SingleChildScrollView Widget 進行滾動檢視的構建;
  • 簡單效能分析。

實踐

目錄結構

目錄結構.png

資料處理

電影詳情 API 返回的欄位更多,同樣可以確認的是 Model 也一定要從網路資料來源中進行拋離,這同樣也為後續構建子元件回填資料時提供方便,我的電影詳情 Model 如下所示:

class MovieMember {
  String id;
  // 成員姓名
  String name;
  // 詳情 URL
  String detailUrl;
  // 中清晰度頭像
  String avatarUrl;

  MovieMember({
    this.name,
    this.detailUrl,
    this.avatarUrl,
  });

  MovieMember.fromJSON(Map<String, dynamic> json) {
    this.id = json['id'];
    this.avatarUrl = json['avatars']['medium'];
    this.detailUrl = json['alt'];
    this.name = json['name'];
  }
}

class MovieDetail {
  // 標題
  String title;
  // 上映年份
  String year;
  // 原名
  String originalTitle;
  // 所屬國家或地區
  List<String> countries;
  // 評分
  String rating;
  // "想看"人數
  String wishCount;
  // 星星
  String stars;
  // 高清晰度海報
  String poster;
  // 電影型別
  List<String> genres;
  // 評分人數
  int ratingsCount;
  // 主要導演
  List<MovieMember> director;
  // 主要演員
  List<MovieMember> casts;
  // 簡介
  String summary;

  MovieDetail({
    this.title,
    this.year,
    this.countries,
    this.rating,
    this.stars,
    this.poster,
    this.genres,
    this.ratingsCount,
    this.director,
    this.casts,
    this.summary,
    this.wishCount,
  });

  MovieDetail.fromJSON(Map<String, dynamic> json) {
    this.title = json['title'];
    this.year = json['year'];
    this.summary = json['summary'];
    this.poster = json['images']['large'];
    this.ratingsCount = json['ratings_count'];
    this.originalTitle = json['original_title'];
    this.wishCount = json['wish_count'].toString();

    this.rating = json['rating']['average'].toString();
    this.stars = json['rating']['stars'].toString();

    this.countries = new List<String>.from(json['countries']);
    this.genres = new List<String>.from(json['genres']);

    List<MovieMember> castsMembers = [];
    (json['directors'] as List).forEach((item) {
      MovieMember movieMember = MovieMember.fromJSON(item);
      castsMembers.add(movieMember);
    });
    this.director = castsMembers;

    List<MovieMember> directorMembers = [];
    (json['casts'] as List).forEach((item) {
      MovieMember movieMember = MovieMember.fromJSON(item);
      directorMembers.add(movieMember);
    });
    this.casts = directorMembers;
  }
}

在寫 MovieDetail Model 時發現電影詳情 API 返回資料來源中的“演員”資料存在多欄位必要資料,為了後續方便呼叫同樣也抽離了一個 MovieMember Model(二期調研估計會繼續做演員詳情)。

演員資料格式.png

網路資料的獲取因為有了上篇文章的鋪墊,這次再寫一個速度明顯快了很多,一期調研完整的網路層方法如下:

import 'dart:io';
import 'dart:convert';
import 'package:movie_top_250/Model/movieModel.dart';

class MovieAPI {
  Future<Movies> getMovieList(int start) async {
    var client = HttpClient();
    int page = start * 50;
    var request = await client.getUrl(Uri.parse(
        'https://api.douban.com/v2/movie/top250?start=$page&count=50'));
    var response = await request.close();
    var responseBody = await response.transform(utf8.decoder).join();
    Map data = json.decode(responseBody);
    return Movies.fromJSON(data);
  }

  Future<MovieDetail> getMovieDetail(String movieId) async {
    var client = HttpClient();
    var request = await client.getUrl(Uri.parse(
        'https://api.douban.com/v2/movie/subject/$movieId'));
    var response = await request.close();
    var responseBody = await response.transform(utf8.decoder).join();
    Map data = json.decode(responseBody);
    return MovieDetail.fromJSON(data);
  }
}

下拉重新整理與上拉載入

下拉重新整理的整體與 Native 開發思路一致。上拉載入有根據業務有很多種實現方案,因為只是為了做驗證性 demo ,直接採取了使用“靜默載入”的思路,當然因為一次載入資料量太大(一頁 50 條),所以在快速滑動列表時會導致下一頁資料未載入等待的體驗。如果不考慮過多的定製化操作,直接使用 flutter 系統元件是一件非常舒服的事情。完整程式碼如下:

import 'package:flutter/material.dart';
import 'package:movie_top_250/Service/movieApi.dart';
import 'package:movie_top_250/Model/movieModel.dart';
import 'package:movie_top_250/View/List/movieListViewRowWidget.dart';

class MovieWidget extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _DouBanMovieState();
  }
}

class _DouBanMovieState extends State<MovieWidget> {
  // 資料來源
  List<Movie> movies = [];
  // 分頁
  int page = 0;

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('豆瓣電影 Top250'),
      ),
      body: new RefreshIndicator(
        child: _buildList(context),
        onRefresh: _requestData,
        color: Colors.black,
      ),
    );
  }

  // 下拉重新整理
  Future<Null> _requestData() async {
    movies.clear();
    await MovieAPI().getMovieList(0).then((moviesData) {
      setState(() {
        movies = moviesData.movies;
      });
    });
    return;
  }

  // 上拉載入
  _requestMoreData(int page) {
    print('page = $page');
    MovieAPI().getMovieList(page).then((moviesData) {
      setState(() {
        movies += moviesData.movies;
      });
    });
  }

  // body List Widget
  Widget _buildList(BuildContext context) {
    var screenWidth = MediaQuery.of(context).size.width;
    if (movies.length != 0) {
      return ListView.separated(
          itemBuilder: (context, index) {
            // 還剩 15 條資料的時去拉取新資料
            if (movies.length - index == 15) {
              _requestMoreData(++page);
            }
            return new Container(
              width: screenWidth,
              child: buildListRow(index, movies[index], context),
            );
          },
          separatorBuilder: (context, index) => Divider(
                height: 1,
              ),
          itemCount: movies.length);
    } else {
      return Center(
        child: CircularProgressIndicator(),
      );
    }
  }
}

UI 分析

上文中也已經說到,因為豆瓣電影詳情公開 API 所暴露出的資料有限,導致未能 100% 的重寫。經過分析後主要將頁面分為了以下幾部分:

豆瓣電影詳情 UI 佈局分析

第一部分

第一部分與上篇文章中所講述的佈局編寫思路大部分一致,對於我自己來說有個需要注意的地方,在第一部分中有個“豆瓣電影排名”的 badge,原本打算是用 RichText Widget 進行實現的,但翻完屬性後發現並沒有提供 decoration 欄位進行修飾,最後直接使用了兩個 DecoratedBox Widget 作為父容器,在其 decoration 屬性下使用 BoxDecoration Widget 完成“一左一右”圓角的 badge 元件編寫,flutter 在元件“半圓角”的實現過程比 iOS 原生實現的程式碼量上少太多了(不封裝的話),實現程式碼如下:

Widget _buildBadge(int index, MovieDetail movieDetail) {
  index++;
  return new Row(
    children: <Widget>[
      DecoratedBox(
          child: Padding(
              padding: EdgeInsets.fromLTRB(7, 3, 7, 3),
              child: Text('No.$index',
                  style: TextStyle(color: Colors.brown, fontSize: 14))),
          decoration: new BoxDecoration(
              color: Colors.orangeAccent,
              borderRadius: BorderRadius.only(
                  topLeft: Radius.circular(5),
                  bottomLeft: Radius.circular(5)))),
      DecoratedBox(
          child: Padding(
              padding: EdgeInsets.fromLTRB(7, 3, 7, 3),
              child: Text('豆瓣Top250',
                  style: TextStyle(color: Colors.orangeAccent, fontSize: 12))),
          decoration: new BoxDecoration(
              color: Colors.black45,
              borderRadius: BorderRadius.only(
                  topRight: Radius.circular(5),
                  bottomRight: Radius.circular(5)))),
    ],
  );
}

在實現“想看”和“看過”兩個按鈕元件時,我使用了 RaisedButton Widget 。一開始是這麼寫的:

new RaisedButton(
  onPressed: null,
  color: Colors.white,
  child: new Text('B'),
  textColor: Colors.black,
)

當顯示出來後,不管怎麼調整樣式、修改顏色、文字等都不管用。最後帶著鬱悶的心情瀏覽官方文件,居然發現了這麼一段話:

RaisedButton 官方解釋

嗯,就算我們並不想讓這個 Button 響應任何點選事件也不能給這個屬性置空,並且也不能刪除,因為這是個必須引數......這點需要注意。讓同樣也沒想到的是 RaisedButton 沒有 text 或類似設定按鈕文字的屬性,而是給了一個 child 屬性,被 UIButton 虐過幾次後在 Flutter 中看到某個元件提供了 child 屬性現在就兩眼放光!完成的程式碼如下:

Widget _buildButton() {
  return new Padding(
    padding: EdgeInsets.fromLTRB(0, 20, 0, 0),
    child: new Row(
      children: <Widget>[
        new Padding(
          padding: EdgeInsets.fromLTRB(0, 0, 10, 0),
          child: new RaisedButton(
            onPressed: () {},
            color: Colors.white,
            child: new Row(
              children: <Widget>[
                new Padding(
                  padding: EdgeInsets.fromLTRB(0, 0, 5, 0),
                  child: new Icon(
                    Icons.remove_red_eye,
                    size: 18,
                    color: Colors.orange,
                  )
                ),
                new Text('想看',
                  style: new TextStyle(
                    color: Color.fromRGBO(100, 100, 100, 1),
                    fontSize: 16,
                    fontWeight: FontWeight.w700,
                  ),
                ),
              ],
            ),
            textColor: Colors.black,
          ),
        ),
        new RaisedButton(
          onPressed: () {},
          color: Colors.white,
          child: new Row(
            children: <Widget>[
              new Padding(
                  padding: EdgeInsets.fromLTRB(0, 0, 5, 0),
                  child: new Icon(
                    Icons.star_border,
                    size: 18,
                    color: Colors.orange,
                  )
              ),
              new Text('看過',
                style: new TextStyle(
                  color: Color.fromRGBO(100, 100, 100, 1),
                  fontSize: 16,
                  fontWeight: FontWeight.w700,
                ),
              ),
            ],
          ),
          textColor: Colors.black,
        ),
      ],
    ),
  );
}

第二部分

這部分佈局與上篇文章所講述的內容都是一致的。並且我也偷懶了,主要是沒有太多值得花費心思的地方,都是常規的佈局.本來想實現下“進度條”,但無奈並沒有真實資料,也就懶得弄了。完整程式碼如下:

import 'package:flutter/material.dart';
import 'package:movie_top_250/Model/movieModel.dart';

Widget movieDetailStarWidget(MovieDetail movieDetail) {
  return new DecoratedBox(
    decoration: new BoxDecoration(
        color: Color.fromRGBO(65, 46, 37, 1),
        borderRadius: BorderRadius.all(Radius.circular(5))),
      child: new Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          new Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              new Padding(
                  padding: EdgeInsets.all(10),
                  child: new Text(
                    '豆瓣評分™',
                    style: TextStyle(color: Colors.white),
                  )
              )
            ],
          ),
          _buildRatingStar(movieDetail),
        ],
      )
  );
}

Widget _buildRatingStar(MovieDetail movieDetail) {
  List<Widget> icons = [];
  int fS = int.parse(movieDetail.stars) ~/ 10;
  int f = 0;

  while (f < fS) {
    icons.add(new Icon(Icons.star, color: Colors.orange, size: 15));
    f++;
  }
  while (icons.length != 5) {
    icons.add(new Icon(Icons.star,
        color: Color.fromRGBO(220, 220, 220, 1), size: 15));
  }

  return new Padding(
    padding: EdgeInsets.fromLTRB(0, 5, 0, 10),
    child: new Column(children: <Widget>[
      new Padding(
        padding: EdgeInsets.fromLTRB(5, 0, 0, 10),
        child: new Text(
          movieDetail.rating,
          style: new TextStyle(
            fontSize: 35,
            fontWeight: FontWeight.w500,
            color: Color.fromRGBO(220, 220, 220, 1),
          ),
        ),
      ),
      new Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: icons
      ),
    ]),
  );
}

第三部分

這部分涉及到了長文字,flutter 中同樣也沒有提供長文字顯示元件,但好在 flutter 的 Text Widget 本身就適用於長文字展示的元件,預設開啟 softWrap 屬性(自動換行)。Text Widget 會從自身節點樹裡一直向上尋找能夠提供寬度約束的父元件,並以此作為單行文字最長顯示寬度,這點還是比較驚訝的,省了非常多的事情。完整的程式碼如下:

import 'package:flutter/material.dart';
import 'package:movie_top_250/Model/movieModel.dart';

Widget movieDetailSummaryWidget(MovieDetail movieDetail) {
  return new Padding(
    padding: EdgeInsets.all(15),
    child: new Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        new Padding(
          padding: EdgeInsets.fromLTRB(0, 0, 0, 10),
          child: new Text(
            '簡介',
            style: new TextStyle(
              fontSize: 20,
              color: Colors.white,
              fontWeight: FontWeight.w600,
            ),
          )
        ),
        new Text(
          movieDetail.summary,
          style: new TextStyle(
              color: Colors.white,
              fontSize: 15,
          )
        )
      ],
    )
  );
}

當資料展示出來後,發現超出當前頁面的顯示範圍了,這也是意料之中。按照之前的做法,會把 UIScrollView 作為當前頁面所有原始的父容器,等所有 UI 元素都回填資料重新渲染完後,再把位於最底部的 UI 元件 bottom 值賦給 scrollView.contentSize

翻了 flutter 文件後,發現了提供常規滑動檢視能力的 Widget 不只一個,最後選擇了 SingleChildScrollView。本以為設定滑動區域的步驟也會向在 Native 中那般麻煩,但實際上只需要把需要滑動檢視元件的父節點賦給 child 屬性即可,SingleChildScrollView 同樣會自動的擴充自己的滑動區域進行適配,如下所示:

Widget _buildBody(BuildContext context) {
    // 資料來源沒來時展示 loading
    if (movieDetail == null) {
      return new Center(
        child: new CircularProgressIndicator(),
      );
    } else {
      return new SingleChildScrollView(
        child: new Padding(
            padding: EdgeInsets.all(10),
            child: new Column(children: <Widget>[
              movieDetailHeaderWidget(rankIndex, movieDetail, context),
              movieDetailStarWidget(movieDetail),
              movieDetailSummaryWidget(movieDetail),
              movieDetailMemberWidget(movieDetail),
            ])
        ),
      );
    }
}

第四部分

這部分是整體比較糾結的地方,到底是基於 GridView Widget 還是 SingleChildScrollView Widget 配合著其它佈局 Widget 去做呢?如果這在 iOS 中,我會毫不猶豫的選擇 UICollectionView 進行構建,因為又快又好~

最後還是抱著“又快又好”目的出發,選擇了 SingleChildScrollView Widget 配合著其它佈局 Widget 去做。需要注意是的 SingleChildScrollView Widget 預設是縱向滾動,該部分豆瓣 App 進行的橫行滾動,需要改變滾動檢視方式。完整程式碼如下:

import 'package:flutter/material.dart';
import 'package:movie_top_250/Model/movieModel.dart';

Widget movieDetailMemberWidget(MovieDetail movieDetail) {
  List<Widget> memberWidgets = [];

  for (MovieMember member in movieDetail.director) {
    memberWidgets.add(_buildMemberWidget(member, true));
  }

  for (MovieMember member in movieDetail.casts) {
    memberWidgets.add(_buildMemberWidget(member, false));
  }

  return new SingleChildScrollView(
    scrollDirection: Axis.horizontal,
    child: new Row(
      children: memberWidgets,
    ),
  );
}

Widget _buildMemberWidget(MovieMember member, bool isDirector) {
  var col = new Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: <Widget>[
      new Container(
        width: 110,
        height: 160,
        decoration: new BoxDecoration(
          image: DecorationImage(image: NetworkImage(member.avatarUrl)),
          borderRadius: new BorderRadius.all(
            const Radius.circular(8.0),
          ),
        ),
      ),
      new Text(member.name,
        style: new TextStyle(
            color: Colors.white,
        ),
      ),
    ],
  );

  if (isDirector) {
    col.children.add(
      new Text(
        '導演',
        style: new TextStyle(
            fontSize: 13,
            color: Color.fromRGBO(150, 150, 150, 1)
        ),
      )
    );
  } else {
    col.children.add(
        new Text(
          '演員',
          style: new TextStyle(
              fontSize: 13,
              color: Color.fromRGBO(150, 150, 150, 1)
          ),
        )
    );
  }

  return new Container(
    width: 110,
    child: new Padding(
      padding: EdgeInsets.all(15),
      child: col,
    ),
  );
}

分析工具

在本 demo 中的 ListView Widget 未做太多優化的地方,所以會導致在啟動 App 完成後直接開始上拉頁面會體驗到卡頓,但實際上的做法是先把當前頁資料來源的條數給 ListView 設定上,當使用者停止滑動頁面後,再開始 load 當前在可視區域範圍內的 ListViewRow Widget 相關子元件資料。這一點優化在 iOS 上通過 UITableView 配合 RunLoop 就可以解決,但在對 flutter 的一期調研中並未打算開始此項調優工作。

使用各種跨平臺工具最令人感到窒息的莫過於除錯了,基於 JSCore 比如 WeexReact-Native 等框架還行,能夠利用 web 開發者工具搭配進行。但比如 XamarinQt 等框架想要進行除錯基本上就比較費勁了,如果框架開發者不提供一些功能完備的 IDE 或外掛,除錯幾乎等於噩夢。好在 Android Stuido 對 flutter 的支援是相當豐富,具體見下圖:

檢視當前頁面節點樹

分析工具

分析工具.png

效能相關(部分)

其它

webView

今天原本還想做跳轉 webView,以為也只是直接調個 webView Widget,填入 requestUrl 屬性就完事了。但當我輸入 web ,IDE 並未提示任何相關資訊時,開始發覺有點不太對勁,不會 flutter 沒有提供 webView Widget 吧?仔細瀏覽後,確認 flutter 還真的沒有提供官方 webView 元件,但在 Pub 上已經有了對應的外掛。

接著去掘金的 flutter 交流群裡諮詢,討論在 flutter 中 webView 以及 JSBridge 最佳思路,最後討論出了兩個外掛:

對於 webView 這塊不是特別滿意,而且看了 flutter 在 github 上的 issue,推薦自己做一個 webView plugin,暴露給 flutter 進行呼叫,這樣可以最大程度上的降低基礎元件重寫成本。仔細一想,其實還是不滿意,所以這部分內容也延後到二期調研中了。

StatefulWidgetStatelessWidget 的選擇

對於開發一個新的元件時,到底是基於 StatefulWidget 還是 StatelessWidget ,我認為只需要明確兩個概念即可:

  • 是否需要更新元件資料來源;
  • 是否需要利用元件各種生命週期;

如果以上兩個條件都符合,那就選擇 StatefulWidget

總結

原始碼地址

本次 flutter 一期調研學習產出的豆瓣電影 Top250 demo 連結地址:movie_top_250

flutter 的這種“宣告式”編碼體驗,我認為對於第一次接觸的新手來說,肯定有需要一定的學習成本,當逼迫自己去熟悉開發思路後,就會覺得真的很過癮。在一期調研的學習中,沒有涉及到的方面有:

  • 設計模式;
  • webViewJSBridge
  • flutter 與 native 的互動;
  • 複雜 UI 的構建;
  • 音視訊處理(這還是得通過 native 進行暴露);

以上幾塊是構建一個 App 所需要具備的基本元件。經過一期學習後,對 flutter 也有了自己的理解,給我最大的感受是因為不用像 React-NativeWeex 等需要回撥節點樹給中間層通知 native 利用原生 UI 框架進行渲染,首先在 UI 繪製速度上已經遠超其它框架,這點是毋庸置疑的。但因為 flutter 還太年輕,一些基礎設施和社群都做得不算太好。所以如果非要選擇一個跨端技術投入實際開發中,我還是會選擇 React-Native,所以將重新撿起來 React-Native, 對其同樣重新進行一期調研。

優秀的人遵守規則,頂尖的人創造規則

相關文章