写完第一个Flutter项目后的总结

/ 6

目前跨平台解决方案有很多,各有各的优缺点。项目技术选型是需要根据自己的需求和技术栈,并结合各种跨平台技术的优缺点去选择。我选择 Flutter 的主要原因是我的本职工作是做原生开发,比起 React Native 等以 JavaScript 为主语言的跨平台技术,Flutter的开发模式对于原生开发会更加友好一些(个人觉得)。

我写下这篇文章也只是为了记录自己的一次跨平台实践,如果能够帮助到正在学习Flutter,或者正在使用Flutter写项目的初学者就更好了。

学习过程

我从开始学习Flutter,到完成第一个项目,一共耗时2个月左右。先学一遍 Dart 语法,再粗略看一遍翻译后的Flutter文档:https://flutterchina.club/docs/ (如果英文水平足够,直接看官方文档更好),让自己对Flutter先有一个整体的认识。

然后看了一本Flutter的书籍: https://book.flutterchina.club/ ,就开始结合搜索引擎和 Stack Overflow 写项目了。如果资金允许,也可以买一套口碑不错的Flutter项目实战课程进行学习,将会更容易上手。

我学习到什么程度,才可以开始写项目呢?

很多新手在写第一个项目的时候,都不敢下手,想继续增加一些知识点才开始写项目。但往往实践才是最有效的学习方式,重复低效的基础学习只会打击自信心。不要怀疑自己,Flutter项目开发上手是很快的,不像其他 webapp 系的跨平台解决方案,还需要你有一定的前端 HTML/CSS/JS 基础。而Flutter只需要你会一点 Widget 知识,就可以开始了。遇到不会的布局,优先想到的是套个 Container ,不要怕层次太多,性能太差,毕竟你才开始写呢。。。

开发中常见的疑问

在开始写项目的时候,会遇到很多问题,这里我列举一些我遇到的,并且认为比较重要的问题。

一、项目结构如何组织?

想要写一个完整的项目,必须要有清晰的项目结构,方便组织代码和快速定位需要的资源。下图是按照我自己的习惯和网上一些开源项目来组织的项目结构:

二、网络请求如何封装?

Flutter网络请求大多都是使用 Dio 库,我这里也是。我们可以将 Dio 库的请求方法封装起来,方便统一处理请求头、响应实体等,也方便后续修改。这种做法有一个比较文雅的名字,叫做隔离网络请求框架

下图是我封装的网络请求(仅供参考):

网络请求类我封装成单例,减少重复对象创建:

class HttpRequest {
  Dio _dio;

  factory HttpRequest() => _getInstance();

  static HttpRequest get instance => _getInstance();
  static HttpRequest _instance;

  HttpRequest._internal() {
    _dio = Dio(
      BaseOptions(
        connectTimeout: 30000,
        receiveTimeout: 30000,
        responseType: ResponseType.json,
      ),
    );
  }

  static HttpRequest _getInstance() {
    if (_instance == null) {
      _instance = HttpRequest._internal();
    }
    return _instance;
  }
}

请求方法提供了返回值和回调方法两种方式来返回响应结果,方便使用 FutureBuilder 和回调调用的方式:

/// 回调方法类型定义
typedef ResultCallback = void Function(ResponseEntity responseEntity);

/// 发送请求
Future<ResponseEntity> request(
    API api, {
    Map<String, dynamic> params,
    ResultCallback successCallback,
    ResultCallback errorCallback,
  }) async

一般后端接口返回的数据格式都是一致的,我们可以定义一个通用的响应实体来接收,比如:

/// 响应实体
class ResponseEntity {
  /// 业务状态码
  int code;

  /// 响应信息
  String message;

  /// 响应数据
  dynamic data;

  ResponseEntity({this.code, this.message, this.data});

  ResponseEntity.fromJson(Map<String, dynamic> json) {
    code = json['code'];
    message = json['message'];
    data = json['data'];
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = new Map<String, dynamic>();
    data['code'] = this.code;
    data['message'] = this.message;
    data['data'] = this.data;
    return data;
  }

  @override
  String toString() {
    return 'ResponseEntity{code: $code, message: $message, data: $data}';
  }
}

然后使用 Dio 去请求接口,并将返回的数据转换成我们定义的响应实体:

Response response = await _dio.get(api.url);
String dataStr = json.encode(response.data);
Map<String, dynamic> dataMap = json.decode(dataStr);
ResponseEntity responseEntity = ResponseEntity.fromJson(dataMap);

注意:这里的请求方式只是作为示例的伪代码,自己封装的时候,需要自己判断请求方法去调用 Dio 不同的请求方法,并传递对应的参数。

三、需不需要JSON转模型?

在原生iOS和Android开发中,绝大部分开发者都会将请求后端接口返回的数据转成 Model/Entity 类去使用。但是在Flutter中,这个问题就是智者见智仁者见仁了。

我个人的做法是:如果接口返回的数据非常简单,并且只需要获取返回数据中的个别字段的数据,可以直接获取。否则建议先把返回的数据转换成 Model/Entity 类再去使用。

我使用的模型创建插件是 Android Studio 中的 FlutterJsonBeanFactory 插件,可以直接搜索安装。

注意:Dart 语言是强类型语言,并且类型确定后不支持自动转换。比如后端返回了一个整数,但是 Model/Entity 中定义的是 double 类型,就会发生错误。我们可以把这种不确定整数还是小数的数值型定义为 num 类型。

四、登录状态和用户数据如何保存?

在前端开发中,大家都习惯直接保存登录返回的token,在下次打开网页时,根据token去请求后端的用户数据,也可以用于判断用户登录是否过期。但是在Flutter开发中,这种方式个人感觉体验并不是很好。

因为网页是放在服务器上的,反正加载网页都需要时间,先请求个用户数据也没啥大不了的。但是在Flutter中,想要获得更流畅的原生体验,还是尽可能多的利用原生的缓存功能,比如用户数据、图片和一些需要直接显示的后端数据。一些数据展示类的页面,还会直接缓存这些数据,让用户在断网状态或者第一次请求接口返回结果前,能直接看到界面效果。

Flutter中缓存数据一般会使用 SQLite 和本地文件缓存,如果是一些文本数据,则可以通过第三方插件(shared_preferences),调用原生的偏好设置去缓存小量文本数据,比如用户账号密码、用户信息等(敏感信息自己加密保存)。

下面是我封装的第三方插件的缓存方法,作用是为了在获取数据的时候直接同步获取:

import 'package:shared_preferences/shared_preferences.dart';

/// 封装持久化工具类
class UserDefaults {
  UserDefaults._internal();

  static UserDefaults get instance => _getInstance();
  static UserDefaults _instance;

  static UserDefaults _getInstance() {
    if (_instance == null) {
      _instance = UserDefaults._internal();
    }
    return _instance;
  }

  static SharedPreferences get sharedPreferences => _sharedPreferences;
  static SharedPreferences _sharedPreferences;

  /// 一定要在APP启动的时候初始化
  static Future<bool> initSharedPreferences() async {
    _sharedPreferences = await SharedPreferences.getInstance();
    return true;
  }

  Future<bool> remove(String key) async {
    return await sharedPreferences.remove(key);
  }

  Future<bool> clear(String key) async {
    return await sharedPreferences.clear();
  }

  Future<bool> setBool(String key, bool value) async {
    return await sharedPreferences.setBool(key, value);
  }

  Future<bool> setInt(String key, int value) async {
    return await sharedPreferences.setInt(key, value);
  }

  Future<bool> setDouble(String key, double value) async {
    return await sharedPreferences.setDouble(key, value);
  }

  Future<bool> setString(String key, String value) async {
    return await sharedPreferences.setString(key, value);
  }

  Future<bool> setStringList(String key, List<String> value) async {
    return await sharedPreferences.setStringList(key, value);
  }

  bool getBool(String key) {
    return sharedPreferences.getBool(key) ?? false;
  }

  int getInt(String key) {
    return sharedPreferences.getInt(key);
  }

  double getDouble(String key) {
    return sharedPreferences.getDouble(key);
  }

  String getString(String key) {
    return sharedPreferences.getString(key);
  }

  List<String> getStringList(String key) {
    return sharedPreferences.getStringList(key);
  }

  bool containsKey(String key) {
    return sharedPreferences.containsKey(key);
  }
}

用户信息缓存示例:

import 'dart:convert';

import 'package:sobenapp/config/key_config.dart';
import 'package:sobenapp/entity/user/merch_info_entity.dart';
import 'package:sobenapp/entity/user/mobile_oauth_token_entity.dart';
import 'package:sobenapp/entity/user/user_info_entity.dart';
import 'package:sobenapp/utils/shared_preferences_util.dart';

/// 账户资料
class Account {
  Account._internal();

  static Account get instance => _getInstance();
  static Account _instance;

  static Account _getInstance() {
    if (_instance == null) {
      _instance = Account._internal();
    }
    return _instance;
  }

  /// token信息
  MobileOauthTokenEntity get mobileOauthTokenEntity => _mobileOauthTokenEntity;
  MobileOauthTokenEntity _mobileOauthTokenEntity;

  /// 用户信息
  UserInfoEntity get userInfoEntity => _userInfoEntity;
  UserInfoEntity _userInfoEntity;

  /// 商户信息
  MerchInfoEntity get merchInfoEntity => _merchInfoEntity;
  MerchInfoEntity _merchInfoEntity;

  /// 每次启动APP都先去加载本地数据
  void load() {
    String tokenInfo = UserDefaults.instance.getString(KeyConfig.tokenInfo);
    if (tokenInfo != null) {
      Account.instance._mobileOauthTokenEntity = MobileOauthTokenEntity.fromJson(json.decode(tokenInfo));
    }

    String userInfo = UserDefaults.instance.getString(KeyConfig.userInfo);
    if (userInfo != null) {
      Account.instance._userInfoEntity = UserInfoEntity.fromJson(json.decode(userInfo));
    }

    String merchInfo = UserDefaults.instance.getString(KeyConfig.merchInfo);
    if (merchInfo != null) {
      Account.instance._merchInfoEntity = MerchInfoEntity.fromJson(json.decode(merchInfo));
    }
  }

  /// 退出登录的时候清理数据
  void clear() {
    UserDefaults.instance.remove(KeyConfig.tokenInfo);
    _mobileOauthTokenEntity = null;

    UserDefaults.instance.remove(KeyConfig.userInfo);
    _userInfoEntity = null;

    UserDefaults.instance.remove(KeyConfig.merchInfo);
    _merchInfoEntity = null;
  }

  /// 保存token信息
  Future<bool> saveMobileOauthToken(Map map) async {
    if (map != null) {
      Account.instance._mobileOauthTokenEntity = MobileOauthTokenEntity.fromJson(map);
      return await UserDefaults.instance.setString(KeyConfig.tokenInfo, json.encode(map));
    } else {
      return false;
    }
  }

  /// 保存用户信息
  Future<bool> saveUserInfo(Map map) async {
    if (map != null) {
      Account.instance._userInfoEntity = UserInfoEntity.fromJson(map);
      return await UserDefaults.instance.setString(KeyConfig.userInfo, json.encode(map));
    } else {
      return false;
    }
  }

  /// 保存商户信息
  Future<bool> saveMerchInfo(Map map) async {
    if (map != null) {
      Account.instance._merchInfoEntity = MerchInfoEntity.fromJson(map);
      return await UserDefaults.instance.setString(KeyConfig.merchInfo, json.encode(map));
    } else {
      return false;
    }
  }
}

在运行APP的时候,初始化插件并获取用户数据:

void main() {
  realRunApp();
}

void realRunApp() async {
  WidgetsFlutterBinding.ensureInitialized();

  // 初始化持久化插件-这个插件必须在runApp之前加载
  bool success = await UserDefaults.initSharedPreferences();
  LogUtil.d("SharedPreferences初始化:" + success.toString());

  runApp(MyApp());
}

class MyApp extends StatefulWidget {
  MyApp({Key key}) : super(key: key);

  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  /// 是否登录
  bool _isLogin = false;

  @override
  void initState() {
    super.initState();
    _isLogin = UserDefaults.instance.containsKey(KeyConfig.tokenInfo);
    if (_isLogin) {
      LogUtil.d("已经登录");
      Account.instance.load();
    } else {
      LogUtil.d("未登录");
    }
  }
}

五、涉及到原生功能怎么办?

当我们需要某些原生功能,或者接入某些原生的SDK时,我们可以在 pub.dev 上搜索有没有现成的插件可供直接使用。比如高德地图、七牛云OSS、ShareSDK、WebView等等,都是已经有现成的插件可以直接使用的。

但是如果没有现成的怎么办?比如我项目中使用了百度AI的OCR SDK,在 pub.dev 上并没有现成的插件,我们就只能直接开发插件。

Flutter的插件开发很简单,但是如果涉及到原生功能的插件开发,就必须要有一定的原生开发基础。就像有一句话:任何混合开发中涉及到原生功能开发的,都需要三端都会。如果不会怎么办?不会就学啊。这里不会详细介绍插件开发过程,网上随便搜Flutter插件开发,有很多文章。

如果只是我们自己项目中使用的插件,建议在项目根目录,也就是 lib 的同级目录下创建一个 plugins 来存放我们创建的Flutter插件项目。并在 pubspec.yaml 中引用。

六、图片素材怎么管理?

随着项目增大,项目中使用的素材也会越来越多。所以项目中的素材最好是在项目开始,就自己管理好。

Flutter是支持直接目录引入的,我们可以把素材根据不同的界面或者不同的模块来分类,然后引入目录。如下图所示:

然后提供一个方便获取图片路径的方法,减少我们在使用过程中的代码量:

/// 获取 assets/images 目录下的图片路径
String getImagePath(String name, {String format = "png"}) {
  return "assets/images/$name.$format";
}

七、屏幕如何适配?

现在手机屏幕的尺寸越来越多,移动端的屏幕适配也一直是一个重要的话题。在原生开发中也出现了一些非常方便的适配框架,比如Android平台的今日头条提供的自动适配框架。在Flutter中,并没有这种自动适配的库,不过也有大佬提供了一个比较方便的屏幕适配库,用来获取和计算屏幕尺寸,就是 flutter_screenutil

八、路由跳转

路由跳转已经有比较成熟的第三方库可直接使用,比如 fluro 。不过为了学习,我第一个项目,是直接使用Flutter的原生的方法。下面是最常用的几个路由跳转方法,一般使用这几个路由跳转方法,足够完成一个项目了。

最简单也是最常用的页面跳转方法,向路由栈中 push 一个路由,类似iOS里的 pushViewController

Navigator.of(context).pushNamed("路由名");

返回上一个页面,类似iOS里的 popViewController

Navigator.of(context).pop();

移除路由栈中的所有路由,再跳转到一个页面,最常用的就是退出登录,并跳转到登录页面:

Navigator.of(context).pushNamedAndRemoveUntil("路由名", (route) => false);

跳转页面后并且不需要再返回。比如在登录页面登录成功后,跳转到主界面:

Navigator.of(context).pushReplacementNamed("路由名");

返回到路由栈的第一个页面,类似iOS里的 popToRootViewController ,比如我在APP里连续 push 了多个页面后,我想直接返回首页,就可以用这个方法。也可以用于返回到路由栈中的某个页面。

Navigator.of(context).popUntil((route) => route.isFirst);

开发期间卡壳的时候

我写这个项目,有2个地方卡了我比较多的时间。就是 AndroidX 适配和涉及页面跳转的插件开发。

建议在项目开始,就适配好 AndroidX ,这样在后续增加第三方库时,也可以顺带留意这个问题。我就是因为最初没有适配 AndroidX ,等到项目开开发完的时候才去适配,就遇到了多个第三方插件不兼容 AndroidX ,导致我需要更换第三方插件,从而浪费了很多时间。

在涉及页面跳转的插件开发中,iOS端可以直接获取到 keyWindowrootViewController 来进行页面跳转。然而 Android 中并没有这么顺心,都知道 Android 中页面跳转是需要上下文作为参数的,我们的上下文从哪里获取?经过我的一顿猛虎操作,结果发现我们可以直接获取到上下文。

public class AipOcrPlugin implements MethodCallHandler {
    public Registrar registrar;

    public AipOcrPlugin(Registrar registrar) {
        this.registrar = registrar;
    }

    public static void registerWith(Registrar registrar) {
        final MethodChannel channel = new MethodChannel(registrar.messenger(), "aip_ocr_plugin");
        channel.setMethodCallHandler(new AipOcrPlugin(registrar));
    }

    @Override
    public void onMethodCall(MethodCall call, Result result) {}
}

然后我们就可以通过 registrar.activity()registrar.context() 获取我们需要的上下文了。

编码习惯

一定要注意抽取 Widget ,一定要注意抽取 Widget ,一定要注意抽取 Widget 。不要一个页面的UI代码都写到一块层层嵌套,这样会导致代码阅读性差、可维护性差,也可能会导致被同事打死。比如下面代码,把基础结构写在 build 方法中,局部的UI单独抽取:

@override
  Widget build(BuildContext context) {
    super.build(context);
    initScreenUtil(context);
    return AnnotatedRegion<SystemUiOverlayStyle>(
      value: SystemUiOverlayStyle.dark,
      child: MediaQuery.removePadding(
        removeTop: true,
        context: context,
        child: Scaffold(
          backgroundColor: Colors.white,
          body: Column(
            children: <Widget>[
              _noticeWidget(),
              Expanded(
                child: ListView(
                  children: <Widget>[
                    _mainFunctionWidget(),
                    _titleBarWidget(),
                    _scrollBannerWidget(),
                    _shareButtonWidget(),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

示例效果图如下所示:

项目中一些通用的UI尽量抽取到单独的类,方便使用。比如一些样式相同的按钮、输入框、弹窗、导航栏等等,类似下图所示,我抽取了一些小部件在 widget 包下:

一些常用的工具类或者代码块,也建议抽取出来,减少重复代码,增加可维护性。