针对这个玩意,你会有以下疑问的话。 你在局域网传文件为什么不用 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 的用户传电子书的需求。
使用方法
第一个打开"速享"的设备,需要创建一个共享窗口,这个窗口类似于群聊的功能。
- 如果所在的局域网不复杂,没有划分子网的话,各端的速享启动后,一端创建房间,其他运行的一端就能直接收到弹窗提示。
- 如果没有弹窗,安卓端可以通过扫描创建房间后显示的二维码加入共享房间,也可以通过创建房间后提示的 url 加入房间。
- 如果只有一端安装了这个客户端,仍可以通过浏览器直接打开创建房间后的 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) {
});
注册监听后就能收到来自局域网的消息。
看一下效果
跟路由器靠得近还是比较快。
相关资料
- UDP multicasting working properly in Dart(Linux) but not in Flutter(Android Emulator)
- 通过UDP广播实现Android局域网Peer Discovering
- Receiving UDP Multicast Not Working
- Android device not receiving multicast package
聊天服务器的实现
起初我是想手搓一个简单的聊天服务器的,简单思路就是,监听套接字的连接,并将已经连接的套接字入一个栈,某一个套接字发过来的消息,服务端遍历已有的栈,将消息进行转发。
但由于需要支持物理平台以及 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();
,然后手机端的各种浏览器的打开了。
开源地址
觉得有帮助给颗⭐️ 吧~
体验地址
目前只编译了 mac 与 windows
安卓端下载的文件在 /sdcard/SpeedShare
enjoy ~ ~ ~