用 Flutter 开发一个局域网互传文件的 App!

7,674 阅读9分钟

针对这个玩意,你会有以下疑问的话。 你在局域网传文件为什么不用 qq,微信,scp,ssh,photoshop,cad,android studio,vs code,xcode,网易云,王者荣耀,天天酷跑呢?

返回键在左上角!

功能列表

  • 类似于 nginx和 tomcat 的文件部署。
  • 像聊天一样在局域网共享文件,点对点连接,不使用服务器中转。
  • 支持图片以及视频消息直接预览(视频预览仅支持 Android 与 Web )
  • 支持多个设备同时分享与查看
  • 下载进度显示、网速显示
  • 浏览器快速加入共享
  • 局域网设备发现功能
  • 历史消息获取

软件前期总会有各种问题,理性反馈,我会在空余的时间修复它。

由于这是个开源的项目,所以你还可以提交 issue 的方式来告知我软件中的问题。

开发的一些经历

我常常会遇到这样的情况,我想要把 PC 上下载的一些大文件传到手机,或者我想把手机上的大文件传到电脑,例如刷机包,电影,于是打开了 QQ 或者微信的文件助手,点击上传,100kb/s 的上传网速,瞬间不开心了😩 。

当我在公司入职后,这样的场景就变得更加的频繁,这个时候就想通过 MIUI+ 的方式来传文件,就我来说,从上大学,90%在用的系统就是 macOS(黑苹果),MIUI+ 所以也不顶用了,而且 MIUI+ 不支持手机热点 PC 连的情况,在公司也是 macOS,用 QQ、微信这些,一样会经过一次服务器,慢得不行,windows 版的 QQ 局域网发送文件到 PC 好像个别时候能走局域网。

而有以上的需求,一般手机与电脑是在一个局域网的,例如我,常常是手机热点带的电脑。

于是用业余的时间,我改造了之前上架的这个 app “速享”,之前这个 app 只支持文件部署,简单说就是类似于 nginx 或者 tomcat 那样的文件部署,最初开发这个的时候并不是自己经常需要这个功能,而是听说这样的功能,会简化使用 kindle 的用户传电子书的需求。

使用方法

第一个打开"速享"的设备,需要创建一个共享窗口,这个窗口类似于群聊的功能。

  1. 如果所在的局域网不复杂,没有划分子网的话,各端的速享启动后,一端创建房间,其他运行的一端就能直接收到弹窗提示。
  2. 如果没有弹窗,安卓端可以通过扫描创建房间后显示的二维码加入共享房间,也可以通过创建房间后提示的 url 加入房间。
  3. 如果只有一端安装了这个客户端,仍可以通过浏览器直接打开创建房间后的 url 加入共享房间,不过此时只能下载文件不能发送文件。

经过比较多的测试,一般手机热点,电脑连接,或者手机电脑连接同一路由器的情况下,创建房间即可收到弹窗。 在比较复杂的局域网下,例如路由器需要连接超过 255,就会通过划分子网的方式,这个时候就无法通过 udp 广播实现设备创建房间的行为广播。

截图

整个软件的页面就俩。

文件选择的实现

这个文件选择我最初是真想用 pub 上的 file_picker 的,但是这个文件管理器选择一个文件后,会把一个文件(无论大小)复制到数据目录内,再返回数据目录内的拷贝后的路径。

举个例子:当使用 file_picker 选择了一个 3g 大小的文件后,整个界面会临时返回,但是代码卡在了 await pick()的那一行,根据设备的读写速度,可能直接几分钟,直到它将这个文件拷贝到数据目录后,我们才拿到这个文件的一些信息,而且此时的路径已经是/data/data/package_name/cache/xxx ,猜测应该是为了兼容 android 高版本而设计,但我并不觉得这是一种好的解决方式。

也有可能是我的失误,如果大家发现 file_picker 可以获取到文件的真实路径欢迎评论区指出。

最后用的自己的一个文件管理器,选图片和视频不太友好,待后续优化。

局域网设备发现功能的实现

经过朋友安利multicast_dns这个包,根本就没 readme,跑了 example 也半天没有跑通,最后用了自己的一种方式,读了multicast_dns的代码,也有点摸不着头脑,但发现也是通过 udp 组播的方式实现的。

安卓端貌似需要

WifiManager wifi;
Context context = getApplicationContext();
wifi = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
mLock = wifi.createMulticastLock("discovery-multicast-lock");
mLock.setReferenceCounted(true);
mLock.acquire();

并添加对应权限才能打开组播消息的接收,因为 android 默认开启组播消息的接收会增加耗电啥的,但是我测到后面,发现不用这也行。

最后实现的代码如下:

import 'dart:convert';
import 'dart:io';

InternetAddress _mDnsAddressIPv4 = InternetAddress('224.0.0.251');
const int _port = 4545;
typedef MessageCall = void Function(String data);

bool _hasMatch(String value, String pattern) {
  return (value == null) ? false : RegExp(pattern).hasMatch(value);
}

/// 抄的getx
extension IpString on String {
  bool get isIPv4 =>
      _hasMatch(this, r'^(?:(?:^|\.)(?:2(?:5[0-5]|[0-4]\d)|1?\d?\d)){4}$');
}

/// 通过组播+广播的方式,让设备能够相互在局域网被发现
class Multicast {
  final int port;
  List<MessageCall> _callback = [];
  RawDatagramSocket _socket;
  bool _isStartSend = false;
  bool _isStartReceive = false;

  Multicast({this.port = _port});

  /// 停止对 udp 发送消息
  void stopSendBoardCast() {
    if (!_isStartSend) {
      return;
    }
    _isStartSend = false;
    _socket.close();
  }

  /// 接收udp广播消息
  Future<void> _receiveBoardCast() async {
    RawDatagramSocket.bind(
      InternetAddress.anyIPv4,
      port,
      reuseAddress: true,
      reusePort: false,
      ttl: 255,
    ).then((RawDatagramSocket socket) {
      // 接收组播消息
      socket.joinMulticast(_mDnsAddressIPv4);
      // 开启广播支持
      socket.broadcastEnabled = true;
      socket.readEventsEnabled = true;
      socket.listen((RawSocketEvent rawSocketEvent) async {
        final Datagram datagram = socket.receive();
        if (datagram == null) {
          return;
        }
        String message = utf8.decode(datagram.data);
        _notifiAll(message);
      });
    });
  }

  void _notifiAll(String data) {
    for (MessageCall call in _callback) {
      call(data);
    }
  }

  Future<void> startSendBoardCast(String data, {Duration duration}) async {
    if (_isStartSend) {
      return;
    }
    _isStartSend = true;
    _socket = await RawDatagramSocket.bind(
      InternetAddress.anyIPv4,
      0,
      ttl: 255,
    );
    _socket.broadcastEnabled = true;
    _socket.readEventsEnabled = true;
    while (true) {
      _boardcast(data);
      if (!_isStartSend) {
        break;
      }
      await Future.delayed(duration ?? Duration(seconds: 1));
    }
  }

  Future<void> _boardcast(String msg) async {
    List<int> dataList = utf8.encode(msg);
    _socket.send(dataList, _mDnsAddressIPv4, port);
    await Future.delayed(const Duration(milliseconds: 10));
    final List<String> address = await _localAddress();
    for (final String addr in address) {
      final tmp = addr.split('.');
      tmp.removeLast();
      final String addrPrfix = tmp.join('.');
      final InternetAddress address = InternetAddress(
        '$addrPrfix\.255',
      );
      _socket.send(
        dataList,
        address,
        port,
      );
    }
  }

  Future<List<String>> _localAddress() async {
    List<String> address = [];
    final List<NetworkInterface> interfaces = await NetworkInterface.list(
      includeLoopback: false,
      type: InternetAddressType.IPv4,
    );
    for (final NetworkInterface netInterface in interfaces) {
      // 遍历网卡
      for (final InternetAddress netAddress in netInterface.addresses) {
        // 遍历网卡的IP地址
        if (netAddress.address.isIPv4) {
          address.add(netAddress.address);
        }
      }
    }
    return address;
  }

  void addListener(MessageCall listener) {
    if (!_isStartReceive) {
      _receiveBoardCast();
      _isStartReceive = true;
    }
    _callback.add(listener);
  }

  void removeListener(MessageCall listener) {
    if (_callback.contains(listener)) {
      _callback.remove(listener);
    }
  }
}

发送消息

multicast.startSendBoardCast('hello');

监听消息

Multicast multicast = Multicast();
multicast.addListener((data) {
  
});

注册监听后就能收到来自局域网的消息。

看一下效果

跟路由器靠得近还是比较快。

相关资料

聊天服务器的实现

起初我是想手搓一个简单的聊天服务器的,简单思路就是,监听套接字的连接,并将已经连接的套接字入一个栈,某一个套接字发过来的消息,服务端遍历已有的栈,将消息进行转发。

但由于需要支持物理平台以及 web,所以我们不能直接用原生的 socket 来实现聊天服务器,这样 web 就会无法连接,于是考虑使用 WebSocket,这儿坑就来了,在物理设备上的 WebSocket 来自 dart:io 而 web 平台的 WebSocket 来自 dart:html,于是考虑抽象出这两个 Websocket 的公共实现,并通过运行时导包来执行对应的平台代码。

后来发现 Getx 的 Websocket 直接就帮我做了这件事儿,直接拉下来用了一下,无问题~版本如下:

  getsocket: ^1.0.0

服务端坑

服务端也是集成在客户端的 app 中的喔~,是用 dart 的一些库编写的服务端。

​ 服务端的代码有个坑,也是我很久都没有解决的,关于这个文件分享的服务端代码实现,我尝试用两个库来做,一个是 http_server ,一个是 shelf_static,前者分享文件的视频,能够很快的缓冲并播放,但是分享这个视频的那端,运行内存的消耗基本是与视频文件大小成正比的,换句话说,如果视频 3g 的话,运行内存会随着播放端的持续缓冲会消耗接近 3g,这一点是不太能接受的,这个在 http_server 的 issue 有提到,但仍是未解决的情况,并且这个仓库已经被官方设置为了Archived

​ 所以我目前换了 shelt_static,而这个包部署的视频,无法缓冲即时播放,好像这部分设计一些后端知识,我没能解决,只有预览端全部下载完事儿后才能播放🤤,我会一直跟进一下这个情况的解决方法。

2021/06/11 已解决,已反馈给 dart 官方仓库 shelf_static/issues/58

Windows 端的坑

由于 windows 是没有根路径存在的,任何路径的开头都是 C:\\ D:\\这样的格式,坑在于如何用 dart 创建一个所有文件的部署,类似 tomcat那样,如何才能访问到所有文件!

考虑了以下实现:

遍历盘符,将每个盘都部署,并将 C:\\转换成/c/这样的形式,然后访问者访问带上 /c来表明需要访问的盘符

但是最后没有用 dart 写出来这个实现。

web 坑

平台判断

我们需要使用Platform.isAndroid等代码,如果运行在 web 上就会报错,因为 web 是没办法用 dart:io 的东西的,那就会考虑再包装一层 if(!kIsWeb),这样就太不优雅了,然后发现在 getx 仓库下的 get_utils有更优雅的实现,具体可看源代码。

目前判断平台的代码:

  if (GetPlatform.isAndroid) 

这个是 get 通过运行时导包,来避免的 web 端访问会异常的情况。

URL的获取

我想要获取 web 端运行时的 url,也就是浏览器上方的链接,而使用dart:html后,物理平台又会报错,最终还是通过运行时导包解决,如下:

document.dart

library document;

export 'document_io.dart' if (dart.library.html) 'document_broswer.dart';

document_io.dart

String url = '';

document_broswer.dart

import 'dart:html';

String url = window.document.baseUri;

移动设备非 chrome 浏览器打不开 flutter web

flutter build web后,发现只有 chrome 能打开这个网页,而且我起初并不知道怎么调试移动端的 web,打开后就白屏,查了一堆 issue 无果,github.com/flutter/flu… ,后来决定换个法子。

通过更改 index.html 代码如下:

<script src="https://cdn.bootcss.com/vConsole/3.2.2/vconsole.min.js"></script>
<script>  var vConsole = new VConsole();  </script>

然后手机端再次打开就能出现一个绿色的调试窗口,有点类似于微信小程序,点击后可以看到日志。

最后发现报错行:

waitForActivation(reg.installing ?? reg.waiting);

那个 ?? 报错

然后发现这是 pwa 相关的一些代码,问了群友报错行是新语法,es6 的特性之类的,整个删掉,只留加载 dart 的一行代码 loadMainDartJs();,然后手机端的各种浏览器的打开了。

开源地址

speed_share

觉得有帮助给颗⭐️ 吧~

体验地址

酷安地址

github下载地址

目前只编译了 mac 与 windows

安卓端下载的文件在 /sdcard/SpeedShare

enjoy ~ ~ ~