feat(lan): 局域网数据面(分支同步,1.1.9)

与主干同一份实现(产品级)。同步前逐个核对基线:分支已有上几轮的改动,
本轮差异是纯新增;viewer_page 的看板自检页因两分支差异大,用锚点精确插入。

要点见主干上那条提交,关键三条:
  · **两套数据源必须互斥** —— 两条都在跑意味着云端流量照烧,
    而那正是本功能要省的东西;
  · **三道闸防重复**:_lanMode 时忽略云端帧 / 轮询按 seq 去重 /
    两条路共用同一个 _ingestMetricsPayload;
  · **不等退订回执就切** —— 在途帧由第一道闸丢掉,等回执只慢一拍。

验证:客户端 383 → **387/387**,analyze 干净,Android release 构建通过。
⚠️ 切换时序仍无自动化覆盖,真机形态验证待两台设备同局域网实测。
This commit is contained in:
PMon Dev committed 2026-09-19 00:16:35 +08:00
1 parent 9f5abdc74d
commit 0d72f13235
6 files changed
+229 -2

No files matched your search

+41
View File
@@ -112,6 +112,12 @@ class LanService {
/// 且没有登记就不会暴露任何东西 —— 服务默认不共享任何文件。
final Map<String, LanOutgoing> _outgoing = {};
/// 供 `/metrics/latest` 取"最新一帧"的提供者。
///
/// 由采集端链路(`link.dart`)注入 —— 这样本模块**不必知道 link 的存在**,
/// 保持"只依赖 dart:io"的干净分层。
Map<String, dynamic>? Function()? frameProvider;
/// 当前登记数(测试与自检用)
int get outgoingCount => _outgoing.length;
@@ -280,6 +286,21 @@ class LanService {
..statusCode = 200
..headers.contentType = ContentType.json
..write(jsonEncode({'ok': true, 'v': 1}));
} else if (req.method == 'GET' && path == '/metrics/latest') {
// 局域网直连取数:查看端改走内网后,靠它拿最新一帧。
//
// ⚠️ 返回的形状**刻意与云端 `server:metrics` 的 payload 一致**
// (device_id / ts / seq / metrics)—— 查看端两套数据源共用一套解析,
// 少一处"内网来的要特殊处理"的分支。
final f = frameProvider?.call();
if (f == null) {
req.response.statusCode = HttpStatus.noContent;
} else {
req.response
..statusCode = 200
..headers.contentType = ContentType.json
..write(jsonEncode(f));
}
} else if (req.method == 'GET' && path.startsWith('/file/')) {
await _serveFile(req, path.substring('/file/'.length));
return; // _serveFile 自己关响应
@@ -644,3 +665,23 @@ LanIncomingOffer? parseIncomingOffer(dynamic envelope) {
size: size,
);
}
/// 内网轮询拿到一帧时,**是否该接受**。
///
/// ── 为什么抽成纯函数 ────────────────────────────────────────────────
/// 这是"局域网直连不把同一帧画两遍"的**核心判据**。嵌在轮询回调里就
/// 只能靠真起 socket 才能验证,而这类"跨层状态不同步"恰恰是本项目
/// 最难复现的一类问题 —— 能单独测就必须单独测。
///
/// 轮询**必然**反复拿到同一帧(2 秒拉一次,而帧可能 10 秒才出一张),
/// 不去重就会把同一帧重复 append 进序列 —— 画面上是原地重复的点。
///
/// 返回 true = 接受(新帧);false = 丢弃(已经处理过)。
bool shouldAcceptLanFrame({required int? lastSeq, required int? seq}) {
// 对端没给 seq:没有依据可判,只能接受。
// 宁可多画一个点,也不要因为"判不了"而把数据全丢了。
if (seq == null) return true;
if (lastSeq == null) return true;
// 只认**严格递增**:等于(重复)与倒退(乱序/重启)都不接受。
return seq > lastSeq;
}
+19
View File
@@ -209,6 +209,9 @@ class ClientAgent {
/// 来自对端的"待领文件"
Stream<LanIncomingOffer> get incomingOffers => _offerCtl.stream;
/// 最近一帧(带 seq),供局域网直连的查看端拉取。
Map<String, dynamic>? _latestForLan;
/// 发布"我这儿有个文件" → 广播给订阅本机的查看端。
///
/// 返回 false 表示**当前没有内网端点**(例如不在任何局域网里)——
@@ -577,6 +580,10 @@ class ClientAgent {
_logLine('采样间隔 $intervalMs ms 超出方案允许的 '
'$kMinSamplingMs–$kMaxSamplingMs ms,已按 $ms ms 执行');
}
// 把"最新一帧"接给局域网服务 —— 查看端走内网时就是从这里取数。
// 注入而不是让它 import link,保持 host 层各模块互不依赖。
LanService.instance.frameProvider = () => _latestForLan;
_sampler?.cancel();
_sampler = Timer.periodic(Duration(milliseconds: ms), (_) {
dispatchSample(
@@ -623,6 +630,18 @@ class ClientAgent {
// 2) Rust 打上 seq 并入待确认队列
final envelope = _transport!.nextMetrics(frame);
// 2b) 留一份给**局域网直连**的查看端拉取。
// ⚠️ **必须带 seq**:查看端的两套数据源(云端推送 / 内网拉取)要落在
// 同一个序号空间里,才能判连续、判重复。少了 seq 就会出现
// "切过去重复画一段"或"切过去少一段",而且两种都很难复现。
// 形状对齐云端 `server:metrics` 的 payload(device_id/ts/seq/metrics)。
_latestForLan = {
'device_id': deviceId,
'ts': envelope['ts'],
'seq': envelope['seq'],
'metrics': frame,
};
// 3) 发出;连接存活则视为送达并出队(见文件头 on_ack 策略说明)
if (_socket?.connected == true) {
_socket!.emit('agent:metrics', envelope);
+142
View File
@@ -583,6 +583,16 @@ class ViewerSession {
String? _lanReachable;
bool _lanProbing = false;
// ── 局域网数据面(LAN 优先取数)────────────────────────────────────
/// 当前是否在**走内网取数**(此时云端订阅已退、云端帧被忽略)
bool _lanMode = false;
Timer? _lanPoll;
/// 内网轮询连续失败次数 —— 够了就回落云端
int _lanMisses = 0;
/// 内网已处理到的最大 seq。**轮询必然重复拿到同一帧**
/// (1 秒一拉、而帧可能 10 秒才出一张),靠它去重。
int? _lanLastSeq;
/// 诊断日志出口。
///
/// 为什么查看端必须有它:采集端有日志面板(`link.dart` 的 `_logLine`),
@@ -658,6 +668,10 @@ class ViewerSession {
/// 正在探测 —— 界面据此显示「检测中」而不是直接说「不可达」
bool get lanProbing => _lanProbing;
/// 当前是否**正在走内网取数**(云端订阅已退)。
/// 这是"局域网直连"是否真的生效的唯一判据 —— 只看端点可达是不够的。
bool get lanMode => _lanMode;
ViewerStatus get status => _status;
ViewerSeries get series => _series;
bool get running => _running;
@@ -754,6 +768,8 @@ class ViewerSession {
_socket = null;
_policy?.dispose();
_policy = null;
_lanPoll?.cancel();
_lanPoll = null;
await _statusCtl.close();
await _seriesCtl.close();
if (!_offerCtl.isClosed) await _offerCtl.close();
@@ -932,6 +948,119 @@ class ViewerSession {
});
}
/// 按"内网可达与否"决定走哪条数据面。
///
/// ── 为什么必须**互斥**,而不是"两条都用" ──────────────────────
/// 同一个设备的两套数据源会各送来同一帧。纵使有 seq 去重,
/// "两条都在跑"也意味着**云端流量照烧** —— 那正是本功能要省的东西。
/// 所以要么走内网、要么走云端,**不同时**。
void _reconsiderLan() {
final reachable = _lanReachable;
if (reachable != null && !_lanMode) {
_enterLanMode(reachable);
} else if (reachable == null && _lanMode) {
_leaveLanMode('内网不可达');
}
}
void _enterLanMode(String endpoint) {
_lanMode = true;
_lanMisses = 0;
_lanLastSeq = null;
// 退掉云端订阅 —— 不退的话云端会把每一帧继续推过来,"省流量"就是空话。
// ⚠️ **不等回执就切**:即使回执在路上,`_lanMode` 已置真,
// `_onMetrics` 会把在途的云端帧丢掉(第一道闸),不会重复。
// 等回执只会让切换慢一拍,换不来确定性。
final target = _status.activeDeviceId;
final s = _socket;
if (target != null && s != null && s.connected) {
s.emit('viewer:unsubscribe', {
'v': 1, 'type': 'viewer:unsubscribe', 'payload': {'device_id': target},
});
}
_startLanPoll(endpoint);
}
void _leaveLanMode(String why) {
_lanMode = false;
_lanPoll?.cancel();
_lanPoll = null;
_lanLastSeq = null;
_lanReachable = null;
_lanMisses = 0;
// 回到云端:**重新订阅**(退订时已从 Redis 摘掉,不重订就再也收不到)
final target = _status.activeDeviceId;
final s = _socket;
if (target != null && s != null && s.connected) {
s.emit('viewer:subscribe', {
'v': 1, 'type': 'viewer:subscribe',
'payload': {
'device_id': target,
if (_token != null && _token!.isNotEmpty) 'token': _token,
},
});
}
// 通知界面刷新一次:"走内网 / 走公网"在自检页上要**立刻**反映出来,
// 否则用户看不出这次回落发生过。回落原因放 lastError 之外的旁路会
// 多一处状态,这里只用一次空 copyWith 触发重建 —— 原因本身不影响判断
// (自检页看到"对端未上报/不可达"就够了)。
_setStatus(_status.copyWith());
}
void _startLanPoll(String endpoint) {
_lanPoll?.cancel();
// 固定 2 秒。**刻意不去猜采样间隔**:`ViewerStatus` 里根本没有它
// (那是采集端的概念),而内网上一个 GET 的开销可以忽略;
// 重复拉到的帧由 seq 去重挡掉(见 _pollLanOnce),不会画重。
_lanPoll = Timer.periodic(const Duration(seconds: 2), (_) {
unawaited(_pollLanOnce(endpoint));
});
}
/// 拉一次内网的最新帧。
Future<void> _pollLanOnce(String endpoint) async {
if (!_lanMode || !_running) return;
final client = HttpClient()..connectionTimeout = const Duration(seconds: 2);
try {
final req = await client
.getUrl(Uri.parse('http://$endpoint/metrics/latest'))
.timeout(const Duration(seconds: 2));
final resp = await req.close().timeout(const Duration(seconds: 2));
if (resp.statusCode != 200) {
// 204 = 对面还没采到帧(正常状态),不算失败
if (resp.statusCode != 204) _lanMisses++;
return;
}
final body = await resp
.transform(const Utf8Decoder(allowMalformed: true))
.join()
.timeout(const Duration(seconds: 2));
final p = jsonDecode(body);
if (p is! Map) {
_lanMisses++;
return;
}
_lanMisses = 0;
final seq = (p['seq'] as num?)?.toInt();
// **按 seq 去重**:轮询必然反复拿到同一帧,不去重就会把同一帧
// 重复 append 进序列(画面上是原地重复的点)。
if (!shouldAcceptLanFrame(lastSeq: _lanLastSeq, seq: seq)) return;
if (seq != null) _lanLastSeq = seq;
_ingestMetricsPayload(p);
} catch (_) {
_lanMisses++;
} finally {
client.close(force: true);
// 连续失败够了就回落 —— 阈值取 3:偶发丢一个请求不该抖回云端,
// 而真的断了(拔网线/换 Wi-Fi)三次足够快地被发现。
if (_lanMisses >= 3 && _lanMode) {
_leaveLanMode('内网连续失败 $_lanMisses 次');
}
}
}
/// 收到"有人要给你一个文件" → 交给界面去确认。
/// **"先确认再收"在界面层做** —— 主机层只负责把事件送上去。
void _onOffer(dynamic data) {
@@ -964,6 +1093,7 @@ class ViewerSession {
if (!_running) return;
_lanProbing = false;
_lanReachable = hit;
_reconsiderLan();
}));
}
@@ -1015,9 +1145,21 @@ class ViewerSession {
}
void _onMetrics(dynamic data) {
// ★ 局域网模式下**直接忽略云端帧** —— 这是"两套数据源不重复"的第一道闸。
// 退订是异步的:回执到达前可能还有在途的云端帧。若这里不拦,
// 同一帧会从两条路各来一次(表现为序列里出现重复点)。
// 第二道闸是 LAN 轮询自己按 seq 去重(见 _pollLanOnce)。
if (_lanMode) return;
final payload = _payloadOf(data);
if (payload == null) return;
_ingestMetricsPayload(payload);
}
/// 摄取一帧 —— **云端推送与内网拉取共用这一条路径**。
///
/// 两条路各写一份解析,迟早出现"一条修了、另一条没修";
/// 而且两边的 seq/断点/能力集处理必须完全一致,否则切过去会看到不同的行为。
void _ingestMetricsPayload(Map payload) {
// 服务端可能同时推多个设备(历史上订阅未清理时),只认当前设备
final deviceId = payload['device_id'] as String? ?? _status.activeDeviceId;
if (deviceId == null || deviceId != _status.activeDeviceId) return;
+4 -1
View File
@@ -480,7 +480,10 @@ class _ViewerPageState extends State<ViewerPage> with WidgetsBindingObserver {
if (s == null) return '无会话';
if (s.lanProbing) return '检测中…';
final hit = s.lanReachable;
if (hit != null) return '可达 · $hit';
// 「可达」与「正在走」是两件事:探测通了但还没切过去(或在切换路上)
// 都要能看出来,否则用户无法判断省流量到底生效没有。
if (hit != null && s.lanMode) return '✓ 走内网 · $hit';
if (hit != null) return '可达(未切换)· $hit';
if (s.lanEndpoints.isEmpty) return '对端未上报(可能不在同一局域网)';
return '不可达 · 已试 ${s.lanEndpoints.join('、')}';
}
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.1.8+18
version: 1.1.9+19
environment:
sdk: ^3.13.3
+22
View File
@@ -181,6 +181,28 @@ void main() {
});
});
group('⑤ 内网取数:同一帧绝不能画两遍', () {
// 轮询**必然**反复拿到同一帧(2 秒拉一次、帧可能 10 秒才出一张),
// 所以这条判据是"不画重"的核心。它是纯函数 —— 能单独测就必须单独测。
test('★ 同一个 seq → 丢弃(轮询反复拉到同一帧是常态,不是异常)', () {
expect(shouldAcceptLanFrame(lastSeq: 41, seq: 41), isFalse);
expect(shouldAcceptLanFrame(lastSeq: 41, seq: 42), isTrue);
});
test('★ seq 倒退 → 丢弃(乱序或对端重启都不该覆盖已有序列)', () {
expect(shouldAcceptLanFrame(lastSeq: 41, seq: 40), isFalse);
});
test('第一帧(还没有 lastSeq)→ 接受', () {
expect(shouldAcceptLanFrame(lastSeq: null, seq: 1), isTrue);
expect(shouldAcceptLanFrame(lastSeq: null, seq: 999), isTrue);
});
test('对端没给 seq → 接受(宁可多画一个点,也不因为判不了而丢数据)', () {
expect(shouldAcceptLanFrame(lastSeq: 41, seq: null), isTrue);
});
});
group('③ 文件传输:不经服务器、且必须验完整', () {
late Directory tmp;
setUp(() async => tmp = await Directory.systemTemp.createTemp('lan_ft_'));