目前跨平台解决方案有很多,各有各的优缺点。项目技术选型是需要根据自己的需求和技术栈,并结合各种跨平台技术的优缺点去选择。我选择 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端可以直接获取到 keyWindow
的 rootViewController
来进行页面跳转。然而 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 包下:
一些常用的工具类或者代码块,也建议抽取出来,减少重复代码,增加可维护性。