使用 Flutter 开发知识小集 iOS/Android 客户端

阅读原文️

Flutter 目前还是 Beta 3 版本,1.0 版本还在路上。不过它在 React Native/weex等跨平台方案之外,又为我们提供了一种跨平台的方案。而且其自身的许多特性,也为我们扩展了新的视野。如果 Fuchsia 系统最终能和 iOS、Android 成三足鼎立之式,甚至于取代 Android,那么 Flutter 就能为我们带来更多的可能。所以现在了解一下还是有必要的。 本文将通过一个简单的实例(知识小集 Flutter 版本客户端,我们后期会慢慢优化),同时半翻译半参考 Raywenderlich 上的 Getting Started with Flutter 这篇文章,来一步步了解如何使用 Flutter 构建 App。

在这个 App 的开发过程中,我们将学习以下关于 Flutter 的内容:

  • 设置开发环境
  • 创建新工程
  • Hot Reload
  • 导入文件
  • 使用 Widget 及自定义 Widget
  • 网络请求
  • 在列表中展示信息
  • 为 App 添加主题

在这个过程中,我们将同时学习一些 Dart 相关的知识。项目的完整代码在 Github 上可以找到。

设置开发环境

我们可以在 macOSLinux 或者 Windows 上开发 Flutter 应用。目前 Flutter 团队为一些 IDE 开发了相应的插件,这些 IDE 包括 IntelliJ IDEAAndroid StudioVisual Studio Code。我的开发环境主要为 macOS + Visual Studio Code,所以本文主要基这两者来进行描述。

实际的配置过程可以参考官方文档 Get Started: Install on macOS。具体的步骤各个平台稍有不同,但主要是以下几步:

  1. 拷贝 Flutter 的 git 库;
  2. 添加 Flutter bin 目录到我们指定的目录;
  3. 运行 flutter doctor 命令,这个命令将告诉我们缺少哪些依赖;
  4. 安装缺失的依赖;
  5. 在 IDE 中安装 Flutter 插件/扩展;
  6. 测试

需要注意的是,如果想在 iOS 模拟器或 iOS 设备上构建和测试应用,我们需要使用 macOS 系统,同时需要安装 Xcode 9.0+

创建新工程

在安装了 Flutter 插件的 VS Code 中,我们可以通过 View > Command Palette... 或者快捷键 cmd+shift+p 来打开 命令面板(command palette),然后输入 Flutter:New Project 并回车:

为工程取名为 awesome_tips_flutter,并回车。选择一个目录来存储工程,然后等待 Flutter 配置好工程。配置的过程主要有几个步骤:

  1. 创建工程所需要的模板文件,包括对应的 iOS 和 Android 工程;
  2. 运行 flutter packages get 命令来获取依赖包;
  3. 运行 flutter doctor 命令来检测依赖包;

如图是构建过程的部分信息:

工程创建完成后,IDE 会默认打开 lib 目录下的 main.dart 文件,这也是我们 App 的入口。

注意:从 Flutter Beta 3 开始,创建 Widget 时,new 关键字是可选的。目前我这生成的模板代码部分还是带 new 关键字的。

在左侧的工程目录中,我们可以看到 iosandroidlib 这些目录,lib 目录下的代码将应用于两个平台,目前我们也主要是在这个目录下工作。

为了构建我们自己的应用,先删除 main.dart 中现有的代码,并用如下代码替代:

import 'package:flutter/material.dart';

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

class AwesomeTips extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Awesome Tips',
      home: Scaffold(
        appBar: AppBar(title: Text('Awesome Tips')),
        body: Center(
          child: Text('Awesome Tips'),
        )
      )
    );
  }
}

顶部的 main() 函数使用 => 操作符来指定单行函数的函数体(类似于 ES6 中的箭头函数),并运行 App。runApp的参数是我们的 AwesomeTipsApp 类(根 Widget)。

在这里,我们的 AwesomeTipsApp 类继承自 StatelessWidget。Flutter 中大部分实体都是 Widget,或者是无状态的(stateless),或者是有状态的(stateful)。我们重写 Widget 的 build() 方法来构建自定义的 App Widget。

我们先来运行一下这个 App。首先启动 iOS 模拟器。选择菜单 Debug -> Start Debugging 构建并运行工程。可以看到 VS Code 打开了 Debug Console (调试控制台) 面板,同时 xcode-builder 开始构建并启动 App。初始效果如下图:

同时,我们可以在 VS Code 顶部看到一个调试工具栏,我们可以通过这个工具栏来停止或者重新加载 App。

Hot Reload

Flutter 开发最吸引人的一个方面就是当程序代码更改时,可以自动执行 Hot Reload 操作,来重新加载 App。我们来试试这个特性,对我们的程序做个小小的修改:

appBar: AppBar(title: Text('Awesome Tips for Test')),

在我们保存文件时,VS Code 会自动启动 Hot Reload 功能,加载完成后,模拟器会显示新的内容。当然我们也可以手动点击调试工具栏上的 Hot Reload 按钮来启动热加载。来看看效果。

注:由于 Flutter 还是 Beta 版,所以 Hot Reload 并不总是能正常工具。我就遇到了类似 Request to Dart VM Service timed out: _flutter.listViews({}) 这样的问题,解决方法是重启 Debug。

导入文件

通常我们都不希望在一个文件中放入大量的代码,而是将代码分散在不同的文件中,并通过一定的方式将这些文件组织起来。然后如果一个文件需要用到其它文件的类或方法,只需要导入相关文件即可。在一个 Dart 文件中,我们可以通过 import 关键字来实现这一目标。

比如上面代码中,我们希望将字符串统一放在一个文件中来管理,那么可以创建一个 strings.dart 文件。在 lib 目录处点击右键,会弹出菜单,选择 New File,并输入文件名。

string.dart 中添加以下代码:

class Strings {
  static String appTitle = "Awesome Tips";
}

然后在 main.dart 中通过以下方式导入:

import 'strings.dart';

现在就可以在 AwesomeTipsApp 中使用 appTitle 了:

class AwesomeTipsApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: Strings.appTitle,
      home: Scaffold(
        appBar: AppBar(title: Text(Strings.appTitle)),
        body: Center(
          child: Text(Strings.appTitle),
        )
      )
    );
  }
}

Widgets

在 Flutter App 中,几乎所有的界面元素都是 Widget。Widget 被设计成是不可变的(immutable),因为这样可以让 App 的 UI 轻量化。我们可以使用两种类型的 Widget:

  • Stateless:无状态 Widget,只依赖于自身的配置信息,例如一个 image view 的静态图片;
  • Stateful:有状态 Widget,需要处理动态信息,并与 State 对象交互。

两种类型的 Widget 都会在 Flutter App 的每一帧进行重绘,不同的是 Stateful Widgets 会将其配置交给 State 对象来管理。关于 Flutter 界面开发,可以参考阿里闲鱼团队 的**《深入了解Flutter界面开发》**一文。

我们现在来创建一个 Widget 展示列表。在 lib 目录中新建文件 content_list.dart,在文件中加入如下代码:

import 'package:flutter/material.dart';

class ContentList extends StatefulWidget {
  @override
  createState() => _ContentListState();
}

这里我们创建了 StatefulWidget 的一个子类 ContentList 并重写了 createState() 方法,该方法返回 ContentList 对应的 State 对象。然后我们在同一文件中添加以下代码:

class _ContentListState extends State<ContentList> {
}

_ContentListState 继承自泛型参数为 ContentList 的 State 对象。在 _ContentListState 中,我们的主要工作就是重写 build() 方法,这个方法在 Widget 被渲染到屏幕上时会调用。目前我们还没有涉及到数据的处理,所以暂时和之前一样,在 ContentList 中显示一个简单的文本。在 build() 方法中添加以下代码:

@override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(Strings.appTitle)),
      body: Text(Strings.appTitle),
    );
  }

Scaffold 类是 Material Design Widgets 的容器。它通常作为 Widget 层级的根。

上面的代码我们添加了一个 AppBar 和一个 body 到 Scaffold 中。接下来我们用这个 ContentList Widget 替换 main.dart 中的 home 属性的内容:

import 'content_list.dart';

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

class AwesomeTipsApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: Strings.appTitle,
      home: ContentList(),				// 替换此处内容
    );
  }
}

编译运行程序,得到的结果和上面差不多。

网络请求及数据转换

我们最终要展示的是知识小集的内容清单,所以需要从服务器上获取到清单内容,并转换成我们需要的 Dart 对象。这里我们需要用到两个库:

  • package:http/http.dart:负责网络请求,从服务端获取数据;
  • dart:convert:将服务端返回的字符串转换成 JSON 对象;

我们在 main.dart 中导入这两个模块:

import 'package:http/http.dart';
import 'dart:convert';

需要注意的是:Dart 应用是单线程的,但是 Dart 支持代码运行在其它线程上,同时也支持使用 async/await 模式让代码异步执行,而不会阻塞 UI 线程。

接下来我们需要通过异步网络调用来获取知识小集的内容列表。首先我们在 _ContentListState 类的顶部添加一个空列表属性,用于保存内容清单:

var _items = [];

Dart 语言中,如果属性/方法名是以_开头,则表示这个属性/方法是类私有的。

然后添加一个 _loadData() 方法,我们在这做网络请求:

void _loadData() async {
    String dataURL =
        "https://app.kangzubin.com/iostips/api/feed/list?page=1&from=flutter-app&version=1.0";
    http.Response response = await http.get(dataURL);

    // ...
  }

这里我们在 _loadData() 后面加上 async 关键字,用于告诉 Dart 这是一个异步方法,同时在 http.get 前使用 await 关键字,来阻塞后面的代码执行。当 HTTP 调用完成后,服务端返回的是一个 JSON 字符串,具体结构如下:

{
  "code": 0,
  "msg": "SUCCESS",
  "data": {}
}

对于 feed/list 接口,其 data 中的结构如下:

"data": {
		"feeds": [{
			"fid": "96",
			"auther": "halohily",
			"title": "如何重写自定义对象的 hash 方法",
			"url": "https://weibo.com/3656155132/GfEGebnEN",
			"platform": "0",
			"postdate": "2018-05-08"
		}, {
			"fid": "95",
			"auther": "南峰子",
			"title": "微博一周推送",
			"url": "https://weibo.com/3321824014/GfviNzT3z",
			"platform": "0",
			"postdate": "2018-05-07"
		}]
	}

在获取到 JSON 字符串后,我们首先需要将其转换成 JSON 对象,然后根据 code 是否为 0 做处理。如果请求成功,则需要从 data 中取出 feeds 的数据。同时,我们希望将 feed 数据转换成一个 Dart 对象,所以我们创建一个 feed.dart 文件,并添加如下代码:

class Feed {
  final String author;
  final String title;
  final String postdate;

  Feed(this.author, this.title, this.postdate);
}

然后我们就可以对返回的数据做处理,将每一条 feed 转换成一个 Feed 对象,并存储在 _items 中。完整的 _loadData() 代码如下所示:

void _loadData() async {
    String dataURL =
        "https://app.kangzubin.com/iostips/api/feed/list?page=1&from=flutter-app&version=1.0";
    http.Response response = await http.get(dataURL);

    final body = JSON.decode(response.body);
    final int code = body["code"];
    if (code == 0) {
      final feeds = body["data"]["feeds"];
      var items = [];
      feeds.forEach((item) =>
          items.add(Feed(item["author"], item["title"], item["postdate"])));

      setState(() {
        _items = items;
      });
    }
  }

如果我们希望在状态改变时,触发界面重新渲染,则需要调用 setState() 方法来设置我们的属性值。

有了加载数据的方法,我们就需要在合适的位置来调用。我们暂且在 _ContentListState 类中重写 State 的 initState() 方法,如下所示:

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

    _loadData();
  }

Widget 生命周期相关的内容,我们有机会再讲。

使用 ListView

至此,我们已经有了列表数据,接下来就需要将数据显示在界面上了。Flutter 提供了 ListView Widget 来显示一个列表,这个 Widget 能很流畅地展示列表内容。

我们先在 _ContentListState 类中添加一个私有方法 _buildRow(),以创建显示单元格的 widget:

Widget _buildRow(int i) {
    Feed feed = this._items[i];

    return ListTile(
        title: Text(
          feed.title,
          overflow: TextOverflow.fade,
        ),
        subtitle: Text(
          '${feed.postdate} @${feed.author}',
        ));
  }

我们暂且返回一个 ListTile 来显示内容的标题及发布日期和作者。接下来我们修改 build() 方法中 Scaffold 的 body:

Widget build(BuildContext context) {
    
    return Scaffold(
      appBar: AppBar(title: Text(Strings.appTitle)),
      body: new ListView.builder(
        padding: const EdgeInsets.all(13.0),
        itemCount: _items.length * 2,
        itemBuilder: (BuildContext context, int position) {

          // 此处为添加分割线
          if (position.isOdd) return Divider();
          final index = position ~/ 2;

          return _buildRow(index);
        },
      ),
    );
  }

在这段代码中,我们通过 ListView.builder 来创建一个 ListView,并通过参数来配置列表的显示。这里我们没有处理单元格点击等事件,后续我们会做改进。

OK,保存代码,Hot Reload 后的效果如下:

很简单吧?这样,我们的任务基本完成。

这里我们只是获取了第1页的数据,分页处理后续再完善。

添加主题(Theme)

最后我们来看看如何为 App 添加主题。可以说这很容易,只需要设置 main.dart 中 MaterialApp 的 theme 属性,我们来试试:

class AwesomeTipsApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: Strings.appTitle,
      theme: ThemeData(primaryColor: Colors.red.shade800),
      home: ContentList(),
    );
  }
}

我们使用了 Material Design 颜色值来设置主题颜色,效果如下:

总结

在本文中,我们通过一个简单的例子来了解了一下如果使用 Flutter 来构建 App,可以在 awesome-tips-flutter-app 下载完整的示例代码。当然,构建一个完整的 App 还需要做很多事情,还有许多技术学习。后期我们会逐步来完善这个 App,并让其达到上线的标准,最终发布到应用市场上。

为了更方便大家获取 Flutter 相关的开发资源,我们在 Github 上开了一个 repo flutter-resources,欢迎大家一起来维护这个 repo。

参考

知识小集是一个团队公众号,主要定位在移动开发领域,分享移动开发技术,包括 iOS、Android、小程序、移动前端、React Native、weex 等。每周都会有 原创 文章分享,我们的文章都会在公众号首发。欢迎关注查看更多内容。

猜你喜欢

转载自juejin.im/post/5afb77126fb9a07aa83ee586