目录
1、背景
在很久以前,我曾经经历了一小段时间的Flutter开发,当时Flutter的版本才迭代到1.0,在做一个短视频应用的开发中,我曾经产生了一个巨大的疑问,就是Flutter的状态刷新怎么才能简洁、高效,如果每次及每个地方都使用setstate()来刷新界面确实显得非常笨重。
这么多年过去了,就像一个回旋镖一样,我又进了一个Flutter/kotlin混合开发的项目,项目是在开源的localsend项目上做二次开发,localsend作为跨平台传输软件,可以实现在同一局域网的端到端设备之间共享文件。本篇博客将以最简单的方式介绍refena创世纪代码、localsend里refena的刷新、localsend的设备扫描流程。
2、refena创世纪代码
由于采用了Flutter作为开发框架,状态管理必不可少,相比于市面常见的Flutter状态管理框架async_redux,localsend采用了不太常用的状态管理框架refena。作为被async_redux启迪的状态管理框架,redux中常见的store、 state、 actions 、 reducers同样适用。store保存了应用里所有的状态,而store被保存在各种provider里;action被保存在自身的reducers里,触发store状态发生变化的唯一办法是发送一个action。有关redux的工作流及基础概念可以参考这篇文章以及下面这张图:
refena官网有最小化的状态刷新介绍,先搬运介绍一下refena的创世纪代码:
final counterProvider = ReduxProvider<ReduxCounter, int>((ref) => Counter());
//1.在refena中,notifier用以保存实际状态,并且可以触发监听它们的控件刷新
//2.init()方法可以定义Notifier初始化状态
//3.可以通过ref来获取定义的其他provider
//4.这里定义counter的初始状态:10
class ReduxCounter extends ReduxNotifier<int> {
int init() => 10;
}
//1.ReduxAction最重要的方法就是reduce()方法,用于向provider返回一个新的状态
//2.这里返回的状态为ReduxCounter现在的值加上传递过来的值
class AddAction extends ReduxAction<ReduxCounter, int> {
final int amount;
AddAction(this.amount);
int reduce() => state + amount;
}
class MyPage extends StatelessWidget {
Widget build(BuildContext context) {
int counterState = context.watch(counterProvider);
return Scaffold(
body: Center(
child: Text('Counter state: $counterState'),
),
floatingActionButton: FloatingActionButton(
//点击触发action dispatch
onPressed: () => context.redux(counterProvider).dispatch(AddAction(2)),
child: const Icon(Icons.add),
),
);
}
}
总结起来,在refena下的工作流为:1、定义初始状态;2、重写reduce()方法,在ReduxAction或各个Action子类中定义要改变的状态;3、定义状态的触发条件,调用dispatch方法触发状态改变。总的来说,refena状态刷新其实和MVVM有许多相似之处。
3、localsend里refena的刷新
localsend典型的功能如下:两个接入同一个局域网的设备相互发送UDP广播或HTTP请求,确立连接之后通过HTTP协议来传输文件。下面将简单介绍localsend扫描到设备后的界面刷新流程。
3.1 初始状态
localsend的初始状态如下:
打开应用,进入发送页签,在“附件的设备”这个列表下开始扫描局域网内的设备。发送页签进行初始化工作,并且发送了一个全局异步的action——SendTabInitAction:
class SendTab extends StatelessWidget {
const SendTab();
Widget build(BuildContext context) {
return ViewModelBuilder(
provider: sendTabVmProvider,
//依然是在init方法里分发初始化action
init: (context) => context.global.dispatchAsync(SendTabInitAction(context)),
......
这个SendTabInitAction的代码十分简单:
class SendTabInitAction extends AsyncGlobalAction {
……
Future<void> reduce() async {
//从provider里边读取是否有扫描到的设备
final devices = ref.read(nearbyDevicesProvider).devices;
if (devices.isEmpty) {
//如果没有设备触发设备扫描流程
await dispatchAsync(StartSmartScan(forceLegacy: false));
}
}
}
根据refena工作流,发起一个action后,会直接调用它的reduce方法,在reduce方法里产生新的状态,并通过各种方式把这个新的状态同步给widget刷新界面。所以,设备是如何扫描出来的只需要跟进StartSmartScan这个action即可。
3.2 发起设备扫描流程
接下来就进入了localsend代码的核心——扫描设备流程,这个流程较为复杂,包括dart下高级网络编程及refena线程间通信等,这里只给出扫描开始及获取到扫描结果的伪代码:
//英文注释为原生代码注释
class StartSmartScan extends AsyncGlobalAction {
static const maxInterfaces = 15;
final bool forceLegacy;
Future<void> reduce() async {
// 1.Try performant Multicast/UDP method first
//首先发起UDP广播,UDP广播性能比TCP/HTTP性能高。
ref.redux(nearbyDevicesProvider).dispatch(StartMulticastScan());
// At the same time, try to discover favorites
//首先从文件里读取是否有收藏的设备
final favorites = ref.read(favoritesProvider);
final https = ref.read(settingsProvider).https;
await ref
.redux(nearbyDevicesProvider)
.dispatchAsync(StartFavoriteScan(devices: favorites, https: https));
……
// 2.If no devices has been found, then switch to legacy discovery mode
// which is purely HTTP/TCP based.
final stillEmpty = ref.read(nearbyDevicesProvider).devices.isEmpty;
final stillInSendTab =
ref.read(homePageControllerProvider).currentTab == HomeTab.send;
if (forceLegacy || (stillEmpty && stillInSendTab)) {
final networkInterfaces =
//localIpProvider保存了从platformChannel里读取到的当前设备的IP
//(这里解释一下为什么当前设备的IP需要单独用一个localIpProvider来保存,
//在android设备里,根据底软实现可能有多个网卡,对应也有多个设备IP)
//依然首先从provider(内存)里读取是否之前扫描到过设备
ref.read(localIpProvider).localIps.take(maxInterfaces).toList();
if (networkInterfaces.isNotEmpty) {
//开始扫描当前设备所有IP所在局域网的设备
await dispatchAsync(StartLegacySubnetScan(subnets: networkInterfaces));
}
} else {
……
}
}
}
3.3 扫描过程
扫描代码主体流程主要分为两个步骤:1.通过UDP协议扫描局域网设备;2.通过TCP/HTTP协议扫描设备。扫描流程较为复杂,需要对网络编程有一定基础的了解。我们跳过具体的扫描流程,直接到获取扫描结果的部分。
//英文注释为localsend原生注释
/// HTTP based discovery on a fixed set of subnets.
class StartLegacySubnetScan extends AsyncGlobalAction {
……
Future<void> reduce() async {
//读取配置信息,端口号、是否是HTTPS协议等
final settings = ref.read(settingsProvider);
final port = settings.port;
final https = settings.https;
// send announcement in parallel
//发起设备扫描流程——UDP组播
ref.redux(nearbyDevicesProvider).dispatch(StartMulticastScan());
await Future.wait<void>([
for (final subnet in subnets)
ref.redux(nearbyDevicesProvider).dispatchAsync(
//发起设备扫描流程——TCP/HTTP请求
StartLegacyScan(port: port, localIp: subnet, https: https)),
]);
……
}
}
/// It does not really "scan".
/// It just sends an announcement which will cause a response on every other LocalSend member of the network.
class StartMulticastScan
extends ReduxAction<NearbyDevicesService, NearbyDevicesState> {
NearbyDevicesState reduce() {
external(notifier._isolateController)
//开启线程发起UDP组播
.dispatch(IsolateSendMulticastAnnouncementAction());
return state;
}
}
/// Scans one particular subnet with traditional HTTP/TCP discovery.
/// This method awaits until the scan is finished.
class StartLegacyScan
extends AsyncReduxAction<NearbyDevicesService, NearbyDevicesState> {
final int port;
final String localIp;
final bool https;
……
Future<NearbyDevicesState> reduce() async {
……
// 1.Scan all IP addresses on the WLAN
final stream = external(notifier._isolateController)
//开启线程发起TCP/HTTP协议设备扫描流程,扫描到的设备保存在Stream里
.dispatchTakeResult(IsolateInterfaceHttpDiscoveryAction(
networkInterface: localIp,
port: port,
https: https,
));
// 2.Register the device to the RegisterDeviceAction
await for (final device in stream) {
//将stream里的设备赋值RegisterDeviceAction
//扫描过的设备保存到nearbyDevicesProvider
await dispatchAsync(RegisterDeviceAction(device));
}
……
}
}
3.3 刷新界面
/// Registers a device in the state.
/// It will override any existing device with the same IP.
class RegisterDeviceAction
extends AsyncReduxAction<NearbyDevicesService, NearbyDevicesState> {
……
Future<NearbyDevicesState> reduce() async {
……
//在RegisterDeviceAction的reduce方法里触发界面刷新
var nearbyDevicesState = state.copyWith(
devices: {...state.devices}
..update(device.ip, (_) => device, ifAbsent: () => device),
);
……
}
}
这样,通过扫描设备流程就将扫描到的设备更新到了界面上:
4.localsend的设备扫描流程
4.1 UDP广播设备注册流程
在前文里,我们已经提到设备扫描首先会以UDP广播来扫描设备,先来看看代码实现。
//英文注释为原生代码注释
/// Binds the UDP port and listen to UDP multicast packages
/// It will automatically answer announcement messages
Stream<Device> startListener() async* {
……
final sockets = await _getSockets(syncState.multicastGroup, syncState.port);
//遍历UDP组播地址的所有Socket
for (final socket in sockets) {
//开始监听是否有组播
socket.socket.listen((_) {
final datagram = socket.socket.receive();
……
try {
//将Socket数据转换为对象
final dto = MulticastDto.fromJson(jsonDecode(utf8.decode(datagram.data)));
if (dto.fingerprint == syncState.securityContext.certificateHash) {
return;
}
……
if ((dto.announcement == true || dto.announce == true) && syncState.serverRunning) {
// only respond when server is running
//向UDP组播广播方返回应答消息
//这里业务逻辑为UDP设备注册流程
//广播发送方作为server
//广播应答方作为client
_answerAnnouncement(peer);
}
} catch (e) {
……
}
});
}
// Tell everyone in the network that I am online
//向UDP组播地址所有成员发送UDP组播,此举可以提供设备扫描成功率
sendAnnouncement(); // ignore: unawaited_futures
yield* streamController.stream;
}
/// Responds to an announcement.
Future<void> _answerAnnouncement(Device peer) async {
try {
// Answer with TCP
//通过dio向广播发送方发起一路HTTP请求,这里的请求接口为设备注册接口
await _ref.read(dioProvider).discovery.post(
ApiRoute.register.target(peer),
data: _getRegisterDto().toJson(),
);
} catch (e) {
……
}
}
/// Sends an announcement which triggers a response on every LocalSend member of the network.
//发送一个广播,在网络的每个localSend成员上触发应答广播消息
Future<void> sendAnnouncement() async {
final syncState = _ref.read(syncProvider);
final sockets = await _getSockets(syncState.multicastGroup);
final dto = _getMulticastDto(announcement: true);
//分别以100ms、500ms、2000ms向发送方应答组播消息
for (final wait in [100, 500, 2000]) {
……
for (final socket in sockets) {
try {
socket.socket.send(dto, InternetAddress(syncState.multicastGroup), syncState.port);
socket.socket.close();
} catch (e) {
……
}
}
}
……
}
Future<List<_SocketResult>> _getSockets(String multicastGroup, [int? port]) async {
//通过各个平台的platformChannel获取当前设备的IP(android设备通常是SoftAP IP)
final interfaces = await NetworkInterface.list();
final sockets = <_SocketResult>[];
for (final interface in interfaces) {
try {
//根据IP地址绑定到localsend预先定义的端口号上,返回一个Socket端点
final socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, port ?? 0);
//把这个Socket加入到UDP组播地址里
socket.joinMulticast(InternetAddress(multicastGroup), interface);
……
} catch (e) {
……
}
}
return sockets;
}
4.2 TCP/HTTP设备注册流程
UDP设备流程结束之后,才会走到HTTP设备注册流程。HTTP设备注册流程资源占用率比UDP广播这种方式大得多。
class HttpScanDiscoveryService {
……
//参数名networkInterface代码当前设备的IP地址。如192.168.31.246
Stream<Device> getStream({required String networkInterface, required int port, required bool https}) {
//遍历197.168.31.0~197.168.31.255里的所有IP,尝试向这里面的每个IP地址发起一路HTTP请求
final ipList = List.generate(256, (i) => '${networkInterface.split('.').take(3).join('.')}.$i').where((ip) => ip != networkInterface).toList();
_runners[networkInterface]?.stop();
_runners[networkInterface] = TaskRunner<Device?>(
initialTasks: List.generate(
ipList.length,
//发起设备注册请求
(index) => () async => _doRequest(ipList[index], port, https),
),
concurrency: 50,
);
return _runners[networkInterface]!.stream.where((device) => device != null).cast<Device>();
}
Future<Device?> _doRequest(String currentIp, int port, bool https) async {
……
final device = await _targetedDiscoveryService.state.discover(
ip: currentIp,
port: port,
https: https,
onError: null,
);
……
}
return device;
}
4.3 localsend的服务器初始化工作
前面讲到了localsend的界面刷新、设备注册流程,还有个问题就是localsend到底如何处理这些广播、请求的?简单来说,localsend在进入应用的时候,跑了一个HTTP server来处理组播、HTTP请求。
/// Starts the server.
Future<ServerState?> startServer({
required String alias,
required int port,
required bool https,
}) async
{
//1.检查用户给localsend客户端取的别名,例如:“美丽的芒果”
alias = alias.trim();
if (alias.isEmpty) {
alias = generateRandomAlias();
}
……
final router = SimpleServerRouteBuilder();
final fingerprint = ref.read(securityProvider).certificateHash;
_receiveController.installRoutes(
router: router,
alias: alias,
port: port,
https: https,
fingerprint: fingerprint,
showToken: ref.read(settingsProvider).showToken,
);
_sendController.installRoutes(
router: router,
alias: alias,
fingerprint: fingerprint,
);
final HttpServer httpServer;
//默认HTTPS协议,需要先安装默认证书
if (https) {
final securityContext = ref.read(securityProvider);
httpServer = await HttpServer.bindSecure(
'0.0.0.0',
port,
SecurityContext()
..usePrivateKeyBytes(securityContext.privateKey.codeUnits)
..useCertificateChainBytes(securityContext.certificate.codeUnits),
);
} else {
//HTTP协议无需证书
httpServer = await HttpServer.bind(
'0.0.0.0',
port,
);
}
//启动服务
final server = SimpleServer.start(server: httpServer, routes: router);
final newServerState = ServerState(
httpServer: server,
alias: alias,
port: port,
https: https,
session: null,
webSendState: null,
pinAttempts: {},
);
state = newServerState;
return newServerState;
}
最后一个问题,localsend作为服务器有哪些RESTful API?根据官方文档,localsend应该提供了以下这些接口:
enum ApiRoute {
//早期的注册接口,现已废弃
info('info'),
//现在版本的注册接口,传递client端IP地址、名称等基础信息
register('register'),
//文件传输之前获取token的接口
prepareUpload('prepare-upload', 'send-request'),
//文件传输接口
upload('upload', 'send'),
//取消接口,包括发送取消、接收取消
cancel('cancel'),
……
;
4.4总结
总结起来,localsend的关键原理:
- 建立一个HTTP Sever。监听相关端口接收UDP组播;初始化RESTful API接口,用于HTTP的设备注册、文件传输;
- UDP设备注册流程中,服务端监听UDP组播端口、客户端回复组播消息并在回复组播消息后发起HTTP注册流程,向服务端传输IP等关键信息;
- TCP设备注册流程中,主动作为客户端遍历当前网段的所有IP,发起一路HTTP请求,向服务端注册;
- 扫描到设备后,通过在服务端/客户端之间的HTTP协议来传输文件。传输过程中,文件发送方为client;文件接收方为server。client发起一路post请求到服务器即可完成文件传输。