Skip to content

网络请求

https://github.com/FunnyFlutter/funny_flutter_server

https://github.com/FunnyFlutter/funny_flutter_server/releases/tag/1.0.0

1
2
3
4
5
git clone https://github.com/FunnyFlutter/funny_flutter_server.git
cd funny_flutter_server
yarn install
# 选择 start  
yarn run

打开 http://localhost:8989/ 可以看到

1
{"error":"","data":"hello world!"}

使用 http 模块发送网络请求

在 Flutter 应用中发送网络请求的过程实际上非常简单,直接使用 http 这个 Dart 包就可以发送一些简单的网络请求了
同时,可以使用 connectivity 包在发送请求以前判断设备的网络状况

请确保本地服务已经开始,同时为了方便调试本地网络哦,建议使用模拟器来调试

首先打开工程中的 pubspec.yaml 文件,在其中添加对 http 和 connectivity 的依赖:

1
2
3
4
dependencies:
  ...
  connectivity: ^3.0.6
  http: ^0.13.6

保存后会自动执行 flutter pub get 命令,就可以在工程中使用 http 和 connectivity 这两个包了。

就下来,完善一下我们所需要的网络请求的逻辑。
在一般的工程实践中,我们会将网络请求相关的代码整合到一个单独的累中,这里我们整合到一个单独的 NetworkClient 中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// lib/model/network_client.dart
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart';

const Map<String, String> commonHeaders = {'Content-Type': 'application/json'};

final String baseUrl =
    Platform.isAndroid ? 'http://10.0.2.2:8989' : 'http://localhost:8980';
final Uri loginUri = Uri.parse('$baseUrl/login');

class NetworkClient {
  NetworkClient._();

  static NetworkClient _client = NetworkClient._();

  factory NetworkClient.instance() => _client;

  Future<String> login(String email, String password) async {
    Map result = Map();
    try {
      Response response = await post(
        loginUri,
        body: jsonEncode({
          'email': email,
          'password': password,
        }),
        headers: commonHeaders,
      );
      result = JsonDecoder().convert(response.body);
    } catch (e) {
      result['error'] = '登录失败\n错误信息为$e';
    }
    return result['error'];
  }
}

这段代码中,我们引入了 http 包,还引入了 Dart 的另外一个核心库: convert。
在这里,convert 库的作用主要是在 JSON 格式的 String 对象和 Map 对象之间互相转化。

之所以这里的请求地址在 Android 平台上是 http://10.0.2.2,是因为这是 Android 模拟器的一个保留地址,请求只有发送给这个地址才是发送给了我们的主机而不是模拟器本身

接下来,在 login.dart 文件中,我们给 "登录" 按钮增加一个点击后的事件处理逻辑,在这个点击事件中,我们首先会利用 connectivity 包提供的功能检查网络是否连通,如果不连通,就弹出一个提示框告知用户设备尚未连入网络:

1
2
// lib/main.dart
home: routes[LOGIN_PAGE_URL]!(context),
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// lib/utils/network.dart
import 'package:connectivity/connectivity.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

Future<bool> checkConnectivityResult(BuildContext context) async {
  ConnectivityResult connectivityResult =
      await (Connectivity().checkConnectivity());
  if (connectivityResult == ConnectivityResult.none) {
    showDialog(
      context: context,
      builder: (BuildContext context) => AlertDialog(
        title: Text('请求失败'),
        content: Text('设备尚未连入网络'),
        actions: <Widget>[
          TextButton(
            onPressed: () {
              Navigator.of(context).pop();
            },
            child: Text('确定'),
          ),
        ],
      ),
    );
    return false;
  }
  return true;
}

接下来,我们添加一下当确定网络连通时,向服务器发送网络请求的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// lib/component/dialog.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class ProgressDialog extends StatelessWidget {
  const ProgressDialog({super.key, this.text});

  final String? text;

  @override
  Widget build(BuildContext context) {
    return Dialog(
      child: Padding(
        padding: const EdgeInsets.only(top: 15.0, bottom: 15.0),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            CircularProgressIndicator(),
            Container(
              width: 20,
              height: 20,
            ),
            Text('请求中...'),
          ],
        ),
      ),
    );
  }
}

class SimpleAlertDialog extends StatelessWidget {
  const SimpleAlertDialog({super.key, this.title, this.content});
  final String? title;
  final String? content;

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: Text(title!),
      content: Text(
        content!,
        maxLines: 3,
      ),
      actions: <Widget>[
        TextButton(
          child: Text('确定'),
          onPressed: () {
            Navigator.of(context).pop();
          },
        ),
      ],
    );
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// lib/pages/login.dart
import 'package:todo/utils/network.dart';

class _LoginPageState extends State<LoginPage> {

  void _login() async {
    if (await checkConnectivityResult(context) == false) {
      return;
    }
    String email = _emailController.text;
    String password = _passwordController.text;
    showDialog(
        context: context, builder: (context) => ProgressDialog(text: '请求中'));
    String result = await NetworkClient.instance().login(email, password);
    Navigator.of(context).pop();
    if (result.isNotEmpty) {
      showDialog(
        context: context,
        builder: (BuildContext context) => SimpleAlertDialog(
          title: '服务器返回信息',
          content: '登录失败,错误信息为: \n $result',
        ),
      );
      return;
    }
    showDialog(
      context: context,
      builder: (BuildContext context) => SimpleAlertDialog(
        title: '服务器返回信息',
        content: '登录成功',
      ),
    );
  }
}

这段代码本身的逻辑也比较简单, 当 "登录" 页面中的 "登录" 按钮被点击时,首先会弹出一个弹框提示我们正在网络请求中,然后调用已经封装好的 NetworkClient 发送登录请求。
当接收到服务器的返回结果时,会根据对应的返回结果提示用户。

保存代码,在邮箱和密码中分别输入 foo@qq.comfoobar,就可以看到登录成功的提示

如果邮箱和密码输入的是 lazy@qq.comlazylazy,服务器就会在收到请求 3 秒之后再返回信息,这样我们就可以简单地模拟一下网络情况比较差时的状态。

将数据缓存在本地

到目前为止,应用中的所有数据都是存储在内存中的。
也就是说,一旦应用结束运行,所有数据便都会消失,除非再次从网络上拉取。
为了改变这种情况,我们需要在应用结束运行之前,将内存中的数据保存在本地的文件系统中。

在一般的应用开发中,本地存储通常有两种形式,一种是比较简单的键值对存储(常简称为 KV 存储),这种方式多用于存储一些比较简单的恶不需查询的信息,例如当前登录的用户名称等。
另外一种是数据库存储,这种方式多用于存储一些数据量较大的有查询需求的信息

保存登录状态

仅通过一个简单的 KV 存储就可以保存登录状态。
KV 存储在 Android 和 iOS 系统上都有简单的实现,因此 Flutter 并没有提供这种存储手段,而是提供了一个桥接了这些原生实现的包,通过 PlatformChannel 直接调用本地写好的存储代码。
针对 KV 存储,我们一般会使用 shared_preferences 包。
要使用这个包,首先需要在 pubspec.yaml 文件中添加对它的依赖:

1
2
3
dependencies:
  ...
  shared_preferences: ^2.1.2

接下来,我们把保存和读取登录状态相关的代码封装到一个单独的类 LoginCenter 中。
由于各个页面都需要用到这个类,因此这里我们将其设计为一个单例的对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// lib/model/login_center.dart
import 'dart:convert';

import 'package:crypto/crypto.dart';
import 'package:shared_preferences/shared_preferences.dart';

const String preferenceKey = 'todo_app_login_key';

class LoginCenter {
  LoginCenter._();

  static LoginCenter _instance = LoginCenter._();
  SharedPreferences? _sharedPreferences;

  factory LoginCenter.instance() => _instance;

  Future<void> _initSharedPreferences() async {
    if (_sharedPreferences == null) {
      _sharedPreferences = await SharedPreferences.getInstance();
    }
  }

  Future<void> logout() async {
    await _initSharedPreferences();
    await _sharedPreferences!.remove(preferenceKey);
  }

  Future<String> currentUserKey() async {
    await _initSharedPreferences();
    if (_sharedPreferences!.containsKey(preferenceKey)) {
      return _sharedPreferences!.getString(preferenceKey)!;
    }
    return '';
  }

  Future<String> login(String email) async {
    await _initSharedPreferences();
    String emailKey = sha256.convert(utf8.encode(email)).toString();
    await _sharedPreferences!.setString(preferenceKey, emailKey);
    return emailKey;
  }
}

在 LoginCenter 类中,主要对外暴露了 login、logout 以及 currentUserKey 三个方法,分别用于登录、退出以及获取当前登录用户的标识。
在这三个方法的实现中,都使用 shared_preferences 包将信息持久化,保证即便应用退出,登录信息也能保存下来。

由于在客户端将邮箱地址明文存储是一件比较有安全风险的事情,因此这里我们使用 crypto 这个 Dart 包将邮箱地址以 hash 值形式存储

1
2
3
dependencies:
  ...
  crypto: ^3.0.3

接下来,回到我们的 HomePage,增加如下代码来使用 LoginCenter 类:

1
2
3
4
5
6
// lib/const/route_argument.dart
class TodoEntryArgument {
  final String userKey;

  TodoEntryArgument(this.userKey);
}
1
2
3
// lib/pages/route_url.dart
const TODO_ENTRY_PAGE_URL = '/entry';
const HOME_PAGE_URL = '/home';
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// lib/pages/home.dart
import 'package:flutter/cupertino.dart';
import 'package:todo/const/route_argument.dart';
import 'package:todo/model/login_center.dart';
import 'package:todo/pages/route_url.dart';

class HomePage extends StatelessWidget {
  void _goToLoginOrTodoEntty(BuildContext context) async {
    String currentUserKey = await LoginCenter.instance().currentUserKey();
    if (currentUserKey.isEmpty) {
      Navigator.of(context).pushReplacementNamed(LOGIN_PAGE_URL);
    } else {
      Navigator.of(context).pushReplacementNamed(
        TODO_ENTRY_PAGE_URL,
        arguments: TodoEntryArgument(currentUserKey),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    _goToLoginOrTodoEntty(context);
    return Container();
  }
}
1
2
3
4
5
6
7
8
9
// lib/main.dart
final Map<String, WidgetBuilder> routes = {
  LOGIN_PAGE_URL: (context) => LoginPage(),
  REGISTER_PAGE_URL: (context) => RegisterPage(),
  TODO_ENTRY_PAGE_URL: (context) => TodoEntryPage(),
  EDIT_TODO_PAGE_URL: (context) => EditTodoPage(),
  HOME_PAGE_URL: (context) => HomePage(),
};
home: routes[HOME_PAGE_URL]!(context),

然后需要在 "登录" 页面和 "关于" 页面中,分别执行对应的登录和退出登录的逻辑,
将对应的登录信息存储到本地或者从本地移除

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// lib/pages/login.dart
class _LoginPageState extends State<LoginPage> {

  void _login() async {
    if (await checkConnectivityResult(context) == false) {
      return;
    }
    String email = _emailController.text;
    String password = _passwordController.text;
    showDialog(
        context: context, builder: (context) => ProgressDialog(text: '请求中'));
    String result = await NetworkClient.instance().login(email, password);
    ...
    Navigator.of(context).pushReplacementNamed(
      TODO_ENTRY_PAGE_URL,
      arguments: TodoEntryArgument(result),
    );
  }
}
1
2
3
4
5
6
7
// lib/pages/about.dart
import 'package:todo/model/login_center.dart';
onPressed: () async {
    await LoginCenter.instance().logout();
    Navigator.of(context)
        .pushReplacementNamed(LOGIN_PAGE_URL);
},

通过这种方式,我们就实现了保存登录状态的功能

保存列表信息

我们已经利用键值存储的方式保存了比较简单的数据,对于比较复杂的列表数据,则需要使用数据库存储的方式。

在移动端,通常使用 SQLite 数据库管理本地数据。
与 shared_preferences 包一样,在 https://pub.dev 中,我们也可以找到一个包 sqflite(https://pub.dev/packages/sqflite) 来操作 SQLite 数据库,
我们可以用这个包完成数据库的存储

sqflite 为我们封装了一系列的数据库存储操作,由于其底层依旧是 SQLite 数据库,因此我们在使用它的过程中也需要写一些 SQL 语句,类似下面这样:

1
2
3
4
5
6
7
8
9
// 打开一个数据库实例
Database database = await openDatabase(
    path,
    version: 1,
    onCreate: (Database db, intversion) async {
        // 在打开数据库后,利用 SQL 创建一个数据表
        await db.execute('CREATE TABLE Text(id INTEGER PRIMARYKEY, name TEXT, value INTEGER, name REAL');
    }
)

为了统一存储到数据库中的 Todo 对象的形式,首先在其中增加一些内容,为其能够存储到数据库中做一些基础支持:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
// lib/model/todo.dart
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';

class Location {
  /// 纬度
  final double latitude;

  /// 经度
  final double longitude;

  /// 地点描述
  final String description;

  /// 默认的构造器
  const Location(
      {this.longitude = 0, this.latitude = 0, this.description = ''});

  /// 命名构造器,用于构造只有描述信息的 Location 对象
  Location.fromDescription(this.description)
      : latitude = 0,
        longitude = 0;
}

class Todo {
  String id = "";
  String title = "";
  String description = "";
  DateTime? date = DateTime.now();
  TimeOfDay startTime = TimeOfDay(hour: 0, minute: 0);
  TimeOfDay endTime = TimeOfDay(hour: 0, minute: 0);
  bool isFinished = false;
  bool isStar = false;
  Priority priority = Priority.Unspecific;
  Location? location;

  Todo({
    String? id,
    this.title = "",
    this.description = "",
    this.date,
    this.startTime = const TimeOfDay(hour: 0, minute: 0),
    this.endTime = const TimeOfDay(hour: 0, minute: 0),
    this.isFinished = false,
    this.isStar = false,
    Priority? priority,
    this.location = const Location(),
  }) : this.id = id ?? generateNewId() {
    // 如果开始时间为空,则设置为当前时间
    if (date == null) {
      date = DateTime.now();
    }
  }

  static Uuid _uuid = Uuid();

  static String generateNewId() => _uuid.v1();

  String get timeString {
    String dateString = date!.compareTo(DateTime.now()) == 0
        ? 'today'
        : '${date!.year}/${date!.month}/${date!.day}';
    if (startTime == null || endTime == null) {
      return dateString;
    }
    return '$dateString ${startTime.hour}:${startTime.minute} - ${endTime.hour}:${endTime.minute}';
  }

  Map<String, dynamic> toMap() {
    return {
      ID: id,
      TITLE: title,
      DESCRIPTION: description,
      DATE: date!.millisecondsSinceEpoch.toString(),
      START_TIME: timeOfDayToString(startTime),
      END_TIME: timeOfDayToString(endTime),
      PRIORITY: priority.value,
      IS_FINISHED: isFinished ? 1 : 0,
      IS_STAR: isStar ? 1 : 0,
      LOCATION_LATITUDE: location?.latitude,
      LOCATION_LONGITUDE: location?.longitude,
      LOCATION_DESCRIPTION: location?.description ?? '',
    };
  }

  static Todo fromMap(Map<String, dynamic> map) {
    return Todo(
      id: map[ID],
      title: map[TITLE],
      description: map[DESCRIPTION],
      date: DateTime.fromMillisecondsSinceEpoch(int.parse(map[DATE])),
      startTime: timeOfDayFromString(map[START_TIME]),
      endTime: timeOfDayFromString(map[END_TIME]),
      priority: Priority.values.firstWhere((p) => p.value == map[PRIORITY]),
      isFinished: map[IS_FINISHED] == 1 ? true : false,
      isStar: map[IS_STAR] == 1 ? true : false,
      location: Location(
        longitude: double.parse(map[LOCATION_LONGITUDE]),
        latitude: double.parse(map[LOCATION_LONGITUDE]),
        description: map[LOCATION_DESCRIPTION],
      ),
    );
  }
}

const String ID = 'id';
const String TITLE = 'title';
const String DESCRIPTION = 'description';
const String DATE = 'date';
const String START_TIME = 'start_time';
const String END_TIME = 'end_time';
const String PRIORITY = 'priority';
const String IS_FINISHED = 'is_finished';
const String IS_STAR = 'is_star';
const String LOCATION_LATITUDE = 'location_latitude';
const String LOCATION_LONGITUDE = 'location_longitude';
const String LOCATION_DESCRIPTION = 'locatioon_description';

timeOfDayToString(TimeOfDay timeOfDay) =>
    '${timeOfDay.hour}:${timeOfDay.minute}';

timeOfDayFromString(String string) {
  return TimeOfDay(
      hour: int.parse(string.split(':').first),
      minute: int.parse(string.split(':').last));
}

然后,将数据库相关的操作封装到一个统一的 DbProvider 类中:

1
2
3
dependencies:
  ...
  sqflite: ^2.2.8+4
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// lib/model/db_provider.dart
import 'package:sqflite/sqflite.dart';
import 'package:todo/model/todo.dart';
import 'package:shared_preferences/shared_preferences.dart';

const String DB_NAME = 'todo_list.db';
const String TABLE_NAME = 'todo_list';
const String CREATE_TABLE_SQL = '''
create table $TABLE_NAME (
  $ID text primary key,
  $TITLE text,
  $DESCRIPTION text,
  $DATE text,
  $START_TIME text,
  $END_TIME text,
  $PRIORITY integer,
  $IS_FINISHED integer,
  $IS_STAR integer,
  $LOCATION_LATITUDE text,
  $LOCATION_LONGITUDE text,
  $LOCATION_DESCRIPTION text
)
''';
const String EDIT_TIME_KEY = 'todo_list_edit_timestamp';

class DbProvider {
  DbProvider(this._dbKey);

  Database? _database;
  SharedPreferences? _sharedPreferences;
  String _dbKey;
  DateTime _editTime = DateTime.fromMillisecondsSinceEpoch(0);
  DateTime get editTime => _editTime;

  String get editTimeKey => '$EDIT_TIME_KEY-$_dbKey';

  Future<List<Todo>> loadFromDataBase() async {
    await _initDataBase();
    if (_sharedPreferences!.containsKey(editTimeKey)) {
      _editTime = DateTime.fromMillisecondsSinceEpoch(
        _sharedPreferences!.getInt(editTimeKey)!,
      );
    }
    List<Map<String, dynamic>> dbRecords = await _database!.query(TABLE_NAME);
    return dbRecords.map((item) => Todo.fromMap(item)).toList();
  }

  Future<int> add(Todo todo) async {
    _updateEditTime();
    return _database!.insert(
      TABLE_NAME,
      todo.toMap(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  Future<int> remove(Todo todo) async {
    _updateEditTime();
    return _database!.delete(
      TABLE_NAME,
      where: '$ID = ?',
      whereArgs: [todo.id],
    );
  }

  Future<int> update(Todo todo) async {
    _updateEditTime();
    return _database!.update(
      TABLE_NAME,
      todo.toMap(),
      where: '$ID = ?',
      whereArgs: [todo.id],
    );
  }

  void _updateEditTime() {
    _editTime = DateTime.now();
    _sharedPreferences!.setInt(editTimeKey, _editTime.millisecondsSinceEpoch);
  }

  Future<void> _initDataBase() async {
    if (_database == null) {
      _database = await openDatabase(
        '$_dbKey\_$DB_NAME',
        version: 1,
        onCreate: (Database database, int version) async {
          await database.execute(CREATE_TABLE_SQL);
        },
      );
    }
    if (_sharedPreferences == null) {
      _sharedPreferences = await SharedPreferences.getInstance();
    }
  }
}

之后,在 TodoList 模型中使用这个 DbProvider 类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// lib/model/todo_list.dart
enum TodoListChangeType {
  Delete,
  Insert,
  Update,
}

class TodoListChangeInfo {
  const TodoListChangeInfo({
    this.todoList = const <Todo>[],
    this.insertOrRemoveIndex = -1,
    this.type = TodoListChangeType.Update,
  });

  final int insertOrRemoveIndex;
  final List<Todo> todoList;
  final TodoListChangeType type;
}

const emptyTodoListChangeInfo = TodoListChangeInfo();

class TodoList extends ValueNotifier<TodoListChangeInfo> {
  List<Todo> _todoList = [];
  DbProvider? _dbProvider;
  final String userKey;

  TodoList(this.userKey) : super(emptyTodoListChangeInfo) {
    _dbProvider = DbProvider(userKey);
    _dbProvider!.loadFromDataBase().then((List<Todo> todoList) async {
      if (todoList.isNotEmpty) {
        todoList.forEach((e) => add(e));
      }
    });
  }

  void add(Todo todo) {
    _todoList.add(todo);
    _dbProvider!.add(todo);
    _sort();
  }

  void remove(String id) {
    Todo? todo = find(id);
    if (todo == null) {
      assert(false, 'Todo with $id is not exist');
      return;
    }
    int index = _todoList.indexOf(todo);
    _todoList.removeAt(index);
    _dbProvider!.remove(todo);
  }

  void update(Todo todo) {
    _dbProvider!.update(todo);
    _sort();
  }

}

在上面这段代码中,我们首先在创建 TodoList 的时候初始化了 DbProvider。
接着在每次数据更新时,都用 DbProvider 将当前的数据存储到数据库中。
最后,在外部的页面中,我们利用 UserKey 作为索引来获取数据库中的数据。这样一来,就完成了本地数据的保存工作

将本地数据上传到网络

仅仅将数据保存在本地是不够的,一个合格的移动应用还需要具备合理的数据备份机制,保证当用户更换设备或者删除应用后,
再次使用应用时之前的数据依旧存在,也就是说还需要将数据上传到网络。
我们会在每次进入应用以及下拉刷新时,从网络获取一次数据并将其与本地保存的数据做对比。
这种情况下,很可能会出现数据不一致的情况,这里我们演示一种比较简单的做法,即仅仅比较两种数据的新旧,然后选择比较新的数据。
同时我们会在应用退出登录或进入后台之前,把所有数据上传到网络。

将数据上传到服务器

从服务器中获取数据的过程也比较简单,我们首先在 NetWorkClient 中增加对应的方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// lib/model/network_client.dart
class NetworkClient {
  ...
  Future<String> uploadList(List<Todo> list, String userKey) async {
    Map result = {};
    var listUri = Uri.parse('$baseUrl/list');
    try {
      Response response = await post(
        listUri,
        body: JsonEncoder().convert({
          'userKey': userKey,
          'timestamp': DateTime.now().millisecondsSinceEpoch,
          'data': list.map((todo) => todo.toMap()).toList(),
        }),
        headers: {
          'Content-Type': 'application/json',
        },
      );
      result = JsonDecoder().convert(response.body);
    } catch (e) {
      result['error'] = '服务器请求失败,请检查网络连接';
    }
    return result['error'];
  }

  Future<FetchListResult> fetchList(String userKey) async {
    FetchListResult result;
    var listUri = Uri.parse('$baseUrl/list?userKey=$userKey');
    try {
      Response response = await get(
        listUri,
        headers: commonHeaders,
      );
      result = FetchListResult.fromJson(JsonDecoder().convert(response.body));
    } catch (e) {
      result = FetchListResult(error: '服务器请求失败,请检查网络连接');
    }
    return result;
    ;
  }
}

class FetchListResult {
  final List<Todo>? data;
  final DateTime? timestamp;
  final String error;

  FetchListResult({this.data, this.timestamp, this.error = ''});

  factory FetchListResult.fromJson(Map<dynamic, dynamic> json) {
    return FetchListResult(
      data: json['data']['data'].map<Todo>((e) => Todo.fromMap(e)).toList(),
      timestamp: DateTime.fromMicrosecondsSinceEpoch(json['data']['timestamp']),
    );
  }
}

然后,在 TodoList 模型中增加获取服务器信息以及处理内容冲突的逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// lib/model/todo_list.dart
class TodoList extends ValueNotifier<TodoListChangeInfo> {
  TodoList(this.userKey) : super(emptyTodoListChangeInfo) {
    _dbProvider = DbProvider(userKey);
    _dbProvider!.loadFromDataBase().then((List<Todo> todoList) async {
      if (todoList.isNotEmpty) {
        todoList.forEach((e) => add(e));
      }
      syncWithNetwork();
    });
  }

  Future<void> syncWithNetwork() async {
    FetchListResult result = await NetworkClient.instance().fetchList(userKey);
    if (result.error.isEmpty) {
      if (_dbProvider!.editTime.isAfter(result.timestamp!)) {
        await NetworkClient.instance().uploadList(list, userKey);
      } else {
        List.from(_todoList).forEach((e) => remove(e.id));
        result.data!.forEach((e) => add(e));
      }
    }
  }

}

这里我们采取的冲突处理策略也十分简单。
首先,在 DbProvider 中增加一个用于记录编辑时间的属性。
每次我们利用 DbProvider 把数据存储到数据库中时都更新一下编辑时间,并将其持久化存储。
之后当我们要和服务端数据同步的时候,就对比一下服务端数据的时间和本地保存的编辑时间,
如果后者晚于前者,就将本地数据上传,反之用网络数据覆盖本地数据。

最后,我们需要在 TodoListPage 和 TodoEntryPage 中增加对应的调用同步数据的代码,这里我们分别在应用回到前台和用户主动触发下拉刷新的时候触发同步数据:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// lib/pages/todo_entry.dart
import 'package:todo/model/network_client.dart';
import 'package:todo/model/todo_list.dart';
class _TodoEntryPageState extends State<TodoEntryPage>
    with WidgetsBindingObserver {
  int currentIndex = 0;
  TodoList? todoList;
  String? userKey;

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.paused) {
      NetworkClient.instance().uploadList(todoList!.list, userKey!);
    }
    if (state == AppLifecycleState.resumed) {
      todoList!.syncWithNetwork();
    }
    super.didChangeAppLifecycleState(state);
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// lib/pages/todo_list.dart
class TodoListPage extends StatefulWidget {
  const TodoListPage({this.todoList});

  final TodoList? todoList;
  @override
  State<TodoListPage> createState() => _TodoListPageState();
}

class _TodoListPageState extends State<TodoListPage> {
  TodoList? todoList;

  @override
  void initState() {
    super.initState();
    todoList = widget.todoList;
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('清单'),
      ),
      body: RefreshIndicator(
        onRefresh: () => widget.todoList!.syncWithNetwork(),
      )
    )
  }

}

修复

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// lib/pages/todo_entry.dart
class _TodoEntryPageState extends State<TodoEntryPage>
    with WidgetsBindingObserver {
  int currentIndex = 0;
  TodoList? todoList;
  String? userKey;
  List<Widget>? pages;

  @override
  void initState() {
    super.initState();
    currentIndex = 0;
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    TodoEntryArgument arguments =
        ModalRoute.of(context)?.settings.arguments as TodoEntryArgument;
    userKey = arguments.userKey;
    todoList = TodoList(userKey!);
    pages = <Widget>[
      TodoListPage(todoList: todoList),
      CalendarPage(),
      Container(),
      ReporterPage(),
      AboutPage(),
    ];
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// lib/pages/todo_list.dart
class _TodoListPageState extends State<TodoListPage> {
  TodoList? todoList;
  GlobalKey<AnimatedListState> animatedListKey = GlobalKey<AnimatedListState>();

  @override
  void initState() {
    super.initState();
    todoList = widget.todoList;
    todoList!.addListener(_updateTodoList);
  }

  void _updateTodoList() {
    TodoListChangeInfo changeInfo = todoList!.value;
    if (changeInfo.type == TodoListChangeType.Update) {
      setState(() {});
    } else if (changeInfo.type == TodoListChangeType.Delete) {
      Todo todo = changeInfo.todoList[changeInfo.insertOrRemoveIndex];
      animatedListKey.currentState!.removeItem(changeInfo.insertOrRemoveIndex, (
        BuildContext context,
        Animation<double> animation,
      ) {
        return SizeTransition(
          sizeFactor: animation,
          child: TodoItem(todo: todo),
        );
      });
    } else if (changeInfo.type == TodoListChangeType.Insert) {
      animatedListKey.currentState!.insertItem(changeInfo.insertOrRemoveIndex);
    } else {
      // do nothing
    }
  }
}

后端返回结果设置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const dataMap = {
  "": {
    "email": "foo@qq.com",
    "timestamp": 1571559479210,
    "data": [
        {
            "id": "1",
            "title": "test",
            "description": "test",
            "date": "1571559479210",
            "start_time": "1:2",
            "end_time": "2:3",
            "priority": 0,
            "is_finished": 0,
            "is_star": 0,
            "location_latitude": "0",
            "location_longitude": "0",
            "location_description": "0"
        }
    ]
  }
};

module.exports = function (request, response) {
  if (request.method === 'GET') {
    const userKey = request.query.userKey;
    console.log('hhhhhhhhhh-' + userKey + "-hhhhh")
    const data = dataMap[userKey] || { data: [], timestamp: 0 };
    console.log(data)
    response.json({
      error: "",
      data: data,
    });
  } else if (request.method === 'POST') {
    const userKey = request.body.userKey;
    if (!userKey) {
      response.json({
        error: `userKey 不应为空`,
        data: [],
      });
      return;
    }
    dataMap[userKey] = {
      data: request.body.data,
      timestamp: request.body.timestamp,
    }
    response.json({
      error: "",
      data: [],
    });
  }
}
1
curl --location 'localhost:8989/list?email=foo%40qq.com&userKey='

返回结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
    "error": "",
    "data": {
        "email": "foo@qq.com",
        "timestamp": 1571559479210,
        "data": [
            {
                "id": "1",
                "title": "test",
                "description": "test",
                "date": "1571559479210",
                "start_time": "1:2",
                "end_time": "2:3",
                "priority": 0,
                "is_finished": 0,
                "is_star": 0,
                "location_latitude": "0",
                "location_longitude": "0",
                "location_description": "0"
            }
        ]
    }
}

最后测试成功的显示:

小结

我们这里学习了如何在多个页面之间共享同一份数据,同时了解了如何使用网络请求以及本地缓存,如果针对不同的缓存需求使用不同的缓存类型,为我们的应用增加了很多十分有用的功能。