Flutter入門——山寨掘金(一)| 掘金技術徵文

MeFelixWang發表於2018-07-23

寫在前面

之前發了一篇文章——《用Flutter山寨一下掘金》,由於是自己學習Flutter時的練手專案,文中完全沒有寫過程,只將原始碼上傳到了GitHub。現受掘金邀請,將文章寫成入門教程,讓對Flutter感興趣的小夥伴都能看懂。

我把專案分成四個小節,按照改版後的掘金app重新寫成了教程,供大家一起學習和交流。由於是入門教程,文中內容不會很深入,對於已經學習過Flutter一段時間的小夥伴,略過就好啦。我是前端開發一枚,也是初學Flutter,文中出現的錯誤,還請小夥伴們指出。

現在開始今天的學習。

正式開始

一. 首先新建專案,名稱隨意

生成的專案結構如下:

Flutter入門——山寨掘金(一)| 掘金技術徵文

在此專案中,我們的業務程式碼都在 lib 下,包配置都在 pubspec.yaml 中。

Flutter入門——山寨掘金(一)| 掘金技術徵文

點選右上角的模擬器按鈕,選擇已經配置好的模擬器,再點選旁邊的綠色三角形,稍等片刻,當你在模擬器中看到下面的效果,恭喜,專案跑起來了:

Flutter入門——山寨掘金(一)| 掘金技術徵文

Tip:
  1. Flutter的安裝和配置小夥伴們就自己完成吧,我使用的是Windows和intellij,參照的是ios版掘金app,小夥伴們後面看到模擬器不要笑啊,因為我買不起蘋果啊,哈哈!
  2. 上圖中的 screenshotsarticles 資料夾是我寫文章用的,小夥伴們不用看。

二、改造根元件

開啟 lib 中的 main.dart 檔案,會看到已經有一些程式碼,有註釋,小夥伴們可以閱讀一下(截圖有點長,貼程式碼有點多,小夥伴們就自己看了哈)。刪掉原有的程式碼,我們開始寫自己的程式碼:

import 'package:flutter/material.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      theme: new ThemeData(
          highlightColor: Colors.transparent,   //將點選高亮色設為透明
          splashColor: Colors.transparent,    //將噴濺顏色設為透明
          bottomAppBarColor: new Color.fromRGBO(244, 245, 245, 1.0),    //設定底部導航的背景色
          scaffoldBackgroundColor: new Color.fromRGBO(244, 245, 245, 1.0),    //設定頁面背景顏色
          primaryIconTheme: new IconThemeData(color: Colors.blue),    //主要icon樣式,如頭部返回icon按鈕
          indicatorColor: Colors.blue,    //設定tab指示器顏色
          iconTheme: new IconThemeData(size: 18.0),   //設定icon樣式
          primaryTextTheme: new TextTheme(    //設定文字樣式
              title: new TextStyle(color: Colors.black, fontSize: 16.0))),
    );
  }
}
複製程式碼

小夥伴們會發現和之前的程式碼有些不一樣,不用驚訝,寫法可以有很多種,以後就明白了。你要是現在點底部的 Hot Reload 或者 Hot Restart 會發現啥也沒有,當然啦,我們啥都還沒寫呢:

Flutter入門——山寨掘金(一)| 掘金技術徵文

Tip:
  1. 頭部的 import 是引入我們需要用的包等東西,這裡引入了 material.dart ,這是一個包含了大量 material 風格的元件的包。
  2. Flutter中的 Widget (元件)有兩類, StatelessWidget是無狀態的, StatefulWidget 是有狀態的,當你的頁面會隨著狀態的改變發生變化時使用。兩者中必有 build 方法,用於建立內容。
  3. MaterialApp 是應用的根元件,這是實現了 material 風格的WidgetsApp ,後面所有的頁面、元件都會在其中。
  4. theme 中是對元件做一些全域性配置。

小夥伴們一定要多看文件哈,雖然文件很多,但要是你不看,你可能會懵逼的,尤其是做前端開發的同志,dart是新語言,語法這些是必須要學習的,我不可能在文中逐行解釋哈,切記!

三、實現app介面結構

lib 資料夾下新建 pages 資料夾,用於存放我們的頁面。然後再 pages 資料夾下新建 index.darthome.dartdiscovery.darthot.dartbook.dartmine.dart ,對應底部的每個tab,這是我們專案中主要會用到的檔案了。

Flutter入門——山寨掘金(一)| 掘金技術徵文

index.dart 檔案中寫入如下內容:

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'home.dart';
import 'hot.dart';
import 'discovery.dart';
import 'book.dart';
import 'mine.dart';

class IndexPage extends StatefulWidget {
  @override
  createState() => new IndexPageState();
}

class IndexPageState extends State<IndexPage> {
//  定義底部導航列表
  final List<BottomNavigationBarItem> bottomTabs = [
    new BottomNavigationBarItem(
      icon: new Icon(CupertinoIcons.home),
      title: new Text('首頁'),
    ),
    new BottomNavigationBarItem(
        icon: new Icon(CupertinoIcons.conversation_bubble),
        title: new Text('沸點')),
    new BottomNavigationBarItem(
        icon: new Icon(CupertinoIcons.search), title: new Text('發現')),
    new BottomNavigationBarItem(
        icon: new Icon(CupertinoIcons.book), title: new Text('小冊')),
    new BottomNavigationBarItem(
        icon: new Icon(CupertinoIcons.profile_circled), title: new Text('我'))
  ];
  final List<Widget> tabBodies = [
    new HomePage(),
    new HotPage(),
    new DiscoveryPage(),
    new BookPage(),
    new MinePage()
  ];
  int currentIndex = 0; //當前索引
  Widget currentPage; //當前頁面

  @override
  void initState() {
    super.initState();
    currentPage = tabBodies[currentIndex];
  }

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return new Scaffold(
      bottomNavigationBar: new BottomNavigationBar(
          type: BottomNavigationBarType.fixed,
          currentIndex: currentIndex,
          items: bottomTabs,
          onTap: (index) {
            setState(() {
              currentIndex = index;
              currentPage = tabBodies[currentIndex];
            });
          }),
      body: currentPage,
    );
  }
}

複製程式碼

上面的程式碼建立了一個即底部有tab按鈕的基本頁面結構,用於切換不同頁面。通過點選事件改變當前索引,來顯示相應的頁面。bottomTabs 可以封裝一下,就留給小夥伴們自己弄了哈,當是練習。

頂部我們引入了一個 Cupertino.dart,這是ios風格的元件,我們還用到了ios的圖示,引入前我們需要到 pubspec.yaml中配置一下,然後點選 Packages get:

Flutter入門——山寨掘金(一)| 掘金技術徵文

Tip:
  1. 因為我們的頁面內容是會切換的,換句話說,狀態會發生改變,所以這裡使用 StatefulWidget
  2. final 關鍵字用於申明一個常量,List<BottomNavigationBarItem> 中的 List 用於申明一個陣列,相當於js中的 Array,後面的 BottomNavigationBarItem 指的是元素的型別。
  3. Scaffold 可能是用得最多的元件了,它對頁面實現了一些結構劃分,其餘的屬性部分,小夥伴們就自己看文件了哈,不難,記住就行。

再次強調,小夥伴們一定要多看文件,不然你真的會懵逼的!

接著我們在其餘檔案中寫入下面的程式碼,只修改頁面名字:

import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';

class HotPage extends StatefulWidget {
  @override
  HotPageState createState() => new HotPageState();
}

class HotPageState extends State<HotPage> {
  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return new Center(child: new Text('沸點'),);
  }
}
複製程式碼

儲存一下,如果你在模擬器上看到下面的內容,就成功了:

Flutter入門——山寨掘金(一)| 掘金技術徵文

tabs也可以用ios風格的CupertinoTabBar實現,此元件的表現和ios原生的一模一樣,留給小夥伴們當練習哈。

四、首頁實現

現在我們來實現首頁,先在 lib 資料夾下新建一個 config 資料夾,並在其中建立 httpHeaders.dart 檔案,寫入下列程式碼:

const httpHeaders = {
  'Accept': '*/*',
  'Accept-Encoding': 'gzip, deflate, br',
  'Accept-Language': 'zh-CN,zh;q=0.9',
  'Connection': 'keep-alive',
  'Host': 'gold-tag-ms.juejin.im',
  'Origin': 'https://juejin.im',
  'Referer': 'https://juejin.im/timeline',
  'User-Agent':
      'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
  'X-Juejin-Client': '1532136021731',
  'X-Juejin-Src': 'web',
  'X-Juejin-Token':
      'eyJhY2Nlc3NfdG9rZW4iOiJWUmJ2dDR1RFRzY1JUZXFPIiwicmVmcmVzaF90b2tlbiI6IjBqdXhYSzA3dW9mSTJWUEEiLCJ0b2tlbl90eXBlIjoibWFjIiwiZXhwaXJlX2luIjoyNTkyMDAwfQ==',
  'X-Juejin-Uid': '59120a711b69e6006865dd7b'
};
複製程式碼

這是掘金的請求頭資訊,後面會用到,先定義在這裡,需要注意的是其中的 X-Juejin-Client 會變化,如果小夥伴們在看文章的時候發現值變了,改一下就行(好像不改也還是能用)。

開啟 home.dart ,在頂部寫入下列程式碼:

import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'dart:convert';
import 'dart:async';
import 'package:http/http.dart' as http;
import '../config/httpHeaders.dart';

複製程式碼

我們新引入了三個包,用來做網路請求。dart:convert用來做資料轉換,dart:async 做非同步,package:http/http.dart 做請求。接著:

/*接著寫*/

class HomePage extends StatefulWidget {
  @override
  HomePageState createState() => new HomePageState();
}

class HomePageState extends State<HomePage> {
//  獲取分類
  Future getCategories() async {
    final response = await http.get(
        'https://gold-tag-ms.juejin.im/v1/categories',
        headers: httpHeaders);
    if (response.statusCode == 200) {
      return json.decode(response.body);
    } else {
      throw Exception('Failed to load categories');
    }
  }

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return FutureBuilder(
      future: getCategories(),
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          var tabList = snapshot.data['d']['categoryList'];
          return new CreatePage(
            tabList: tabList,
          );
        } else if (snapshot.hasError) {
          return Text("error1>>>>>>>>>>>>>>>:${snapshot.error}");
        }
        return new Container(
          color: new Color.fromRGBO(244, 245, 245, 1.0),
        );
      },
    );
  }
}
複製程式碼

這部分我們先獲取獲取掘金頂部的分類列表,Future 類似於 Promise ,用來做非同步請求, FutureBuilder 函式用來在請求返回後構建頁面,返回的狀態、資料等資訊都在 snapshot 中(前端的同志們看到 asyncawait 是不是很眼熟?)。這裡我們把構建頁面的程式碼提取出來,不然巢狀太多讓人崩潰,並把獲取的tabs傳下去。我這裡用的 FutureBuilder,小夥伴們也可以用文件中的寫法,看上去還會更簡潔,不過既然是學習嘛,寫寫也無妨。

/*接著寫*/
//建立頁面
class CreatePage extends StatefulWidget {
  final List tabList;

  @override
  CreatePage({Key key, this.tabList}) : super(key: key);

  CreatePageState createState() => new CreatePageState();
}

class CreatePageState extends State<CreatePage>
    with SingleTickerProviderStateMixin {
  @override
  Widget build(BuildContext context) {
    //TODO: implement build
    return new DefaultTabController(
        length: widget.tabList.length,
        child: new Scaffold(
          appBar: new AppBar(
            backgroundColor: new Color.fromRGBO(244, 245, 245, 1.0),
            automaticallyImplyLeading: false,
            titleSpacing: 0.0,
            title: new TabBar(
                indicatorSize: TabBarIndicatorSize.label,
                isScrollable: true,
                labelColor: Colors.blue,
                unselectedLabelColor: Colors.grey,
                tabs: widget.tabList.map((tab) {
                  return new Tab(
                    text: tab['name'],
                  );
                }).toList()),
            actions: <Widget>[
              new IconButton(
                  icon: new Icon(
                    Icons.add,
                    color: Colors.blue,
                  ),
                  onPressed: () {
                    Navigator.pushNamed(context, '/shareArticle');
                  })
            ],
          ),
          body: new TabBarView(
              children: widget.tabList.map((cate) {
                return ArticleLists(
                  categories: cate,
                );
              }).toList()),
        ));
  }
}
複製程式碼

這部分用於建立tab選項和tab頁面,DefaultTabController 是建立 tabBarView 的一個簡單元件,以後小夥伴們可以自己實現個性化的 tabBarViewaction 裡我已經把路由寫進去了,等我們把頁面寫完,再去實現路由。我們把構建文章列表的程式碼也提出來,當每點選一個tab,就把對應的tab資訊傳入,查詢文章會需要tab項中的 id

/*接著寫*/
class ArticleLists extends StatefulWidget {
  final Map categories;

  @override
  ArticleLists({Key key, this.categories}) : super(key: key);

  ArticleListsState createState() => new ArticleListsState();
}

class ArticleListsState extends State<ArticleLists> {
  List articleList;

  Future getArticles({int limit = 20, String category}) async {
    final String url =
        'https://timeline-merger-ms.juejin.im/v1/get_entry_by_rank?src=${httpHeaders['X-Juejin-Src']}&uid=${httpHeaders['X-Juejin-Uid']}&device_id=${httpHeaders['X-Juejin-Client']}&token=${httpHeaders['X-Juejin-Token']}&limit=$limit&category=$category';
    final response = await http.get(Uri.encodeFull(url));
    if (response.statusCode == 200) {
      return json.decode(response.body);
    } else {
      throw Exception('Failed to load post');
    }
  }

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return new FutureBuilder(
        future: getArticles(category: widget.categories['id']),
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            articleList = snapshot.data['d']['entrylist'];
            return new ListView.builder(
                itemCount: articleList.length,
                itemBuilder: (context, index) {
                  var item = articleList[index];
                  return createItem(item);
                });
          } else if (snapshot.hasError) {
            return new Center(
              child: new Text("error2>>>>>>>>>>>>>>>:${snapshot.error}"),
            );
          }
          return new CupertinoActivityIndicator();
        });
  }
}
複製程式碼

我們把單個文章的構建程式碼也提出來,讓程式碼看著舒服點。

class ArticleListsState extends State<ArticleLists> {
/*接著寫*/

//單個文章
  Widget createItem(articleInfo) {
    var objectId = articleInfo['originalUrl']
        .substring(articleInfo['originalUrl'].lastIndexOf('/') + 1);
    var tags = articleInfo['tags'];
    return new Container(
      margin: new EdgeInsets.only(bottom: 10.0),
      padding: new EdgeInsets.only(top: 10.0, bottom: 10.0),
      child: new FlatButton(
        padding: new EdgeInsets.all(0.0),
        onPressed: () {
          Navigator.push(
              context,
              new CupertinoPageRoute(
                  builder: (context) => ArticleDetail(
                        objectId: objectId,
                        articleInfo: articleInfo,
                      )));
        },
        child: new Column(
          children: <Widget>[
            new Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: <Widget>[
                new FlatButton(
                    onPressed: null,
                    child: new Row(
                      children: <Widget>[
                        new CircleAvatar(
                          backgroundImage: new NetworkImage(
                              articleInfo['user']['avatarLarge']),
                        ),
                        new Padding(padding: new EdgeInsets.only(right: 5.0)),
                        new Text(
                          articleInfo['user']['username'],
                          style: new TextStyle(color: Colors.black),
                        )
                      ],
                    )),
                //控制是否顯示tag,及顯示多少個    
                tags.isNotEmpty
                    ? (tags.length >= 2
                        ? new Row(
                            mainAxisAlignment: MainAxisAlignment.end,
                            children: <Widget>[
                              new FlatButton(
                                  onPressed: null,
                                  child: new Text(
                                    tags[0]['title'].toString(),
                                    style: new TextStyle(fontSize: 14.0),
                                  )),
                              new Text('/'),
                              new FlatButton(
                                  onPressed: null,
                                  child: new Text(
                                    tags[1]['title'].toString(),
                                    style: new TextStyle(fontSize: 14.0),
                                  ))
                            ],
                          )
                        : new FlatButton(
                            onPressed: null,
                            child: new Text(
                              tags[0]['title'].toString(),
                              style: new TextStyle(fontSize: 14.0),
                            )))
                    : new Container(
                        width: 0.0,
                        height: 0.0,
                      )
              ],
            ),
            new ListTile(
              title: new Text(articleInfo['title']),
              subtitle: new Text(
                articleInfo['summaryInfo'],
                maxLines: 2,
              ),
            ),
            new Row(
              children: <Widget>[
                new FlatButton(
                    onPressed: null,
                    child: new Row(
                      children: <Widget>[
                        new Icon(Icons.favorite),
                        new Padding(padding: new EdgeInsets.only(right: 5.0)),
                        new Text(articleInfo['collectionCount'].toString())
                      ],
                    )),
                new FlatButton(
                    onPressed: null,
                    child: new Row(
                      children: <Widget>[
                        new Icon(Icons.message),
                        new Padding(padding: new EdgeInsets.only(right: 5.0)),
                        new Text(articleInfo['commentsCount'].toString())
                      ],
                    ))
              ],
            )
          ],
        ),
      ),
      color: Colors.white,
    );
  }
}
複製程式碼

每個文章中的互動我這裡就不做那麼全了,不然篇幅太大,樣式小夥伴們也自己調吧,這個花時間。

Flutter入門——山寨掘金(一)| 掘金技術徵文

在單個文章的按鈕裡我已經寫好了跳轉函式,就是 onPressed 中的程式碼,裡面用到的 CupertinoPageRoute 主要是ios風格的滑動動畫,我們來實現詳情頁。

五、實現文章詳情頁

pages 資料夾下新建 articleDetail.dart 檔案,flutter目前還不支援渲染 html ,因此我們這裡需要引入一個外掛 flutter_html_view,這個外掛支援的標籤也不是很多,但目前差不多夠用了,為作者點個贊。開啟 pubspec.yaml 檔案,在 dependencies 下寫入依賴:

dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^0.1.2
  flutter_html_view: "^0.5.1"
複製程式碼

然後在 articleDetail.dart 頂部引入:

import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'dart:convert';
import 'dart:async';
import 'package:http/http.dart' as http;
import '../config/httpHeaders.dart';
import 'package:flutter_html_view/flutter_html_view.dart';
複製程式碼

接著就是寫頁面了:

class ArticleDetail extends StatefulWidget {
  final String objectId;
  final Map articleInfo;

  @override
  ArticleDetail({Key key, this.objectId, this.articleInfo}) : super(key: key);

  @override
  ArticleDetailState createState() => new ArticleDetailState();
}

class ArticleDetailState extends State<ArticleDetail> {
  Future getContent() async {
    final String url =
        'https://post-storage-api-ms.juejin.im/v1/getDetailData?uid=${httpHeaders['X-Juejin-Src']}&device_id=${httpHeaders['X-Juejin-Client']}&token=${httpHeaders['X-Juejin-Token']}&src=${httpHeaders['X-Juejin-Src']}&type=entryView&postId=${widget
        .objectId}';
    final response = await http.get(Uri.encodeFull(url));
    if (response.statusCode == 200) {
      return json.decode(response.body)['d'];
    } else {
      throw Exception('Failed to load content');
    }
  }

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    var articleInfo = widget.articleInfo;
    return new FutureBuilder(
        future: getContent(),
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            var content = snapshot.data['content'];
            return new Scaffold(
                appBar: new AppBar(
                  backgroundColor: new Color.fromRGBO(244, 245, 245, 1.0),
                  leading: new IconButton(
                      padding: new EdgeInsets.all(0.0),
                      icon: new Icon(
                        Icons.chevron_left,
                      ),
                      onPressed: () {
                        Navigator.pop(context);
                      }),
                  title: new Row(
                    children: <Widget>[
                      new CircleAvatar(
                        backgroundImage: new NetworkImage(
                            articleInfo['user']['avatarLarge']),
                      ),
                      new Padding(padding: new EdgeInsets.only(right: 5.0)),
                      new Text(articleInfo['user']['username'])
                    ],
                  ),
                  actions: <Widget>[
                    new IconButton(
                        icon: new Icon(
                          Icons.file_upload,
                          color: Colors.blue,
                        ),
                        onPressed: null)
                  ],
                ),
                bottomNavigationBar: new Container(
                  height: 50.0,
                  padding: new EdgeInsets.only(left: 10.0, right: 10.0),
                  decoration: new BoxDecoration(
                      color: new Color.fromRGBO(244, 245, 245, 1.0),
                      border: new Border(
                          top: new BorderSide(width: 0.2, color: Colors.grey))),
                  child: new Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: <Widget>[
                      new Row(
                        children: <Widget>[
                          new Icon(
                            Icons.favorite_border,
                            color: Colors.green,
                            size: 24.0,
                          ),
                          new Padding(
                              padding: new EdgeInsets.only(right: 20.0)),
                          new Icon(
                            Icons.message,
                            color: Colors.grey,
                            size: 24.0,
                          ),
                          new Padding(
                              padding: new EdgeInsets.only(right: 20.0)),
                          new Icon(
                            Icons.playlist_add,
                            color: Colors.grey,
                            size: 24.0,
                          )
                        ],
                      ),
                      new Text(
                          '喜歡 ${articleInfo['collectionCount']} · 評論 ${articleInfo['commentsCount']}')
                    ],
                  ),
                ),
               );
          } else if (snapshot.hasError) {
            return new Container(
              color: Colors.white,
              child: new Text("error2>>>>>>>>>>>>>>>:${snapshot.error}"),
            );
          }
          return new Container(
            color: new Color.fromRGBO(244, 245, 245, 1.0),
            child: new CupertinoActivityIndicator(),
          );
        });
  }
}
複製程式碼

html 寫入頁面的就是下面這段程式碼:

 body: new ListView(
                  children: <Widget>[
                    new Container(
                        color: Colors.white,
                        child: new HtmlView(
                          data: content,
                        ))
                  ],
                )
複製程式碼

細心的小夥伴會發現,bottomNavigationBar 中傳入的是一個有高度的 Container ,這個很重要,flutter中的元件其實是很靈活的,不要被官網提供的元件限制了,只要滿足條件(比如bottomNavigationBar 必須傳入 PreferredSizeWidget),各種各樣的自定義元件都可以用。

點贊、評論啥的我們先不做,用過掘金app的小夥伴都知道,這些功能是需要登入後才能用的,所以我們放到後面來實現。

Flutter入門——山寨掘金(一)| 掘金技術徵文

結尾叨叨

自己是初學flutter,最近也很忙,文中的錯誤和不足,還請大家原諒,歡迎指出,一起學習,今天就到這裡了。原始碼點這裡

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

相關文章