feat(connection): 增加远程视频录制、分辨率/帧率切换及高刷适配

- 新增 VideoRecorder 基于 flutter_webrtc MediaRecorder 录制远程视频为 MP4
- 新增 ControlMessageDecoder 解析被控端上报的 REPORT_* protobuf 消息
- 连接控制器支持分辨率/帧率切换、录制状态管理与远程上报处理
- 远程控制页新增更多菜单(静音/分辨率/帧率/录制),并统一退出时断开连接
- WebRTC 编排器在 DataChannel 打开及媒体就绪后主动请求默认分辨率,解决黑屏问题
- 新增 DisplayModeUtil 在 Android 上启用最高刷新率
- 登出时失效设备相关 Provider,避免切换账号后读到旧缓存
This commit is contained in:
TongTongStudio
2026-08-27 11:39:42 +08:00
parent 41a514e9cc
commit bfb106f1fb
13 changed files with 1267 additions and 514 deletions

1
.gitignore vendored
View File

@@ -46,3 +46,4 @@ app.*.map.json
# Widget Preview related
.widget_preview/
android/.kotlin/
.codebuddy/

View File

@@ -0,0 +1,35 @@
import 'dart:io' show Platform;
import 'package:flutter_displaymode/flutter_displaymode.dart';
/// 高刷新率(高刷)适配工具。
///
/// 部分 Android 设备出于省电考虑,默认不会启用屏幕的最高刷新率
/// (如 120Hz / 144Hz导致 Flutter 渲染帧率被系统限制在 60Hz 以下。
/// 通过 [FlutterDisplayMode] 主动将显示模式切换到设备支持的最高刷新率,
/// 从而提升动画与滚动流畅度。
///
/// - Android通过 `flutter_displaymode` 请求最高刷新率模式。
/// - iOSProMotion 设备由系统 + Flutter 引擎自动匹配(无需额外配置,
/// `Info.plist` 中已设置 `CADisableMinimumFrameDurationOnPhone`)。
class DisplayModeUtil {
const DisplayModeUtil._();
/// 在应用启动时调用,将 Android 屏幕切换到最高刷新率。
///
/// 保持当前分辨率不变,仅将刷新率提升到最高可用档位(如 60 → 120Hz
/// 非关键流程,失败仅打印日志,不阻塞启动。仅在 Android 平台生效。
static Future<void> enableBestDisplayMode() async {
if (!Platform.isAndroid) {
return;
}
try {
// 官方便捷方法:在当前分辨率下选择最高刷新率的显示模式。
await FlutterDisplayMode.setHighRefreshRate();
} catch (e) {
// 高刷适配为可选优化,失败不影响主流程。
// ignore: avoid_print
print('enableBestDisplayMode failed: $e');
}
}
}

View File

@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_exception.dart';
import '../../../core/network/error_message.dart';
import '../../../core/storage/token_storage.dart';
import '../../device/data/device_providers.dart';
import '../data/auth_providers.dart';
import '../domain/auth_models.dart';
import '../domain/auth_state.dart';
@@ -132,10 +133,16 @@ class AuthController extends Notifier<AuthState> {
}
/// 登出。
///
/// 除清除令牌与登录态外,还需失效当前账号的已绑定设备列表及由其推导的
/// 选中设备 SN 等 Provider否则切换账号登录后仍会读到上一账号缓存的设备/
/// SN 数据(`myDevicesProvider` / `selectedDeviceSnProvider` 等持有旧值)。
void logout() {
_countdownTimer?.cancel();
TokenStorage.clear();
state = const AuthState();
// 失效当前账号相关的设备与选中 SN 缓存,避免下一账号登录后沿用旧 SN。
ref.invalidate(myDevicesProvider);
}
Future<bool> _run(Future<LoginResult> Function() action) async {

View File

@@ -137,3 +137,102 @@ class ControlMessageEncoder {
}
}
}
/// 被控端上报的远程视频信息REPORT_RESOLUTION / REPORT_STREAM_MODE
class ReportInfo {
final int action;
final int width;
final int height;
final int fps;
final int streamMode;
final List<int> supportedFps;
const ReportInfo({
this.action = 0,
this.width = 0,
this.height = 0,
this.fps = 0,
this.streamMode = 0,
this.supportedFps = const [],
});
}
/// protobuf wire format 解码器。
///
/// 与控制端 `com.ttstd.control.ControlMessage`protobuf 3字段号完全对齐
/// 仅解析被控端会回传的字段action/width/height/fps/stream_mode/supported_fps
class ControlMessageDecoder {
/// 解析被控端上报的 REPORT_* 消息;无法解析返回 null。
static ReportInfo? decode(Uint8List data) {
int action = 0;
int width = 0;
int height = 0;
int fps = 0;
int streamMode = 0;
final supportedFps = <int>[];
final reader = ByteData.sublistView(data);
var pos = 0;
try {
while (pos < data.length) {
final tag = _readVarint(reader, pos);
pos = tag.$2;
final field = tag.$1 >> 3;
final wireType = tag.$1 & 0x07;
switch (wireType) {
case 0: // varint
final v = _readVarint(reader, pos);
pos = v.$2;
final val = v.$1;
switch (field) {
case 1:
action = val;
break;
case 12:
width = val;
break;
case 13:
height = val;
break;
case 14:
fps = val;
break;
case 15:
streamMode = val;
break;
case 16:
supportedFps.add(val);
break;
}
break;
default:
// 未知 wire type跳过解析到 known fields 前不应出现)
return null;
}
}
} catch (_) {
return null;
}
return ReportInfo(
action: action,
width: width,
height: height,
fps: fps,
streamMode: streamMode,
supportedFps: supportedFps,
);
}
/// 读取一个 varint返回 (值, 新的位置)。越界抛异常。
static (int, int) _readVarint(ByteData data, int pos) {
var result = 0;
var shift = 0;
while (true) {
final byte = data.getUint8(pos++);
result |= (byte & 0x7F) << shift;
if ((byte & 0x80) == 0) break;
shift += 7;
}
return (result, pos);
}
}

View File

@@ -0,0 +1,99 @@
import 'dart:io';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:path_provider/path_provider.dart';
/// 远程视频录制器:基于 flutter_webrtc 的 [MediaRecorder]
/// 把远端视频轨道MediaStreamTrack直接封装为 MP4 保存到本地。
///
/// 跨平台Android / iOS由 flutter_webrtc 内部完成 H.264 编码与封装,
/// 无需自行触碰原生 VideoSink。
///
/// 录制文件保存位置:
/// - Android/Android/data/&lt;包名&gt;/files/WebRTCRecordings/app 专属外部存储,无需存储权限)
/// - iOS&lt;App&gt;/Documents/WebRTCRecordings/
class VideoRecorder {
MediaRecorder? _recorder;
bool _recording = false;
String? _currentPath;
/// 是否正在录制。
bool get isRecording => _recording;
/// 是否为空(从未真正开始)。
String? get currentPath => _currentPath;
/// 开始录制指定媒体流中的视频轨道。
///
/// 返回 true 表示成功开始;空流 / 无视频轨道会抛异常。
Future<bool> start(MediaStream stream) async {
if (_recording) return false;
final videoTracks = stream.getVideoTracks();
if (videoTracks.isEmpty) {
throw Exception('当前没有可用的远端视频轨道,无法录制');
}
final path = await _buildOutputPath();
// ignore: avoid_print
print('[VideoRecorder] 录制开始,保存地址:$path');
_recorder = MediaRecorder(albumName: 'WebRTCRecordings');
await _recorder!.start(path, videoTrack: videoTracks.first);
_currentPath = path;
_recording = true;
return true;
}
/// 停止录制,返回最终保存的文件路径;未开始录制返回 null。
Future<String?> stop() async {
if (!_recording || _recorder == null) return null;
try {
await _recorder!.stop();
} catch (e) {
// 停止失败时仍清理状态,避免界面卡在"录制中"
// ignore: avoid_print
print('[VideoRecorder] stop error: $e');
} finally {
_recorder = null;
_recording = false;
}
final p = _currentPath;
_currentPath = null;
// ignore: avoid_print
print('[VideoRecorder] 录制结束,已保存至:$p');
return p;
}
/// 释放资源(断开连接 / 页面销毁时调用)。
Future<void> dispose() async {
if (_recording && _recorder != null) {
try {
await _recorder!.stop();
} catch (_) {
// 忽略
}
}
_recorder = null;
_recording = false;
_currentPath = null;
}
static String _pad(int n) => n.toString().padLeft(2, '0');
static Future<Directory> _getRecordingDir() async {
final base = Platform.isAndroid
? (await getExternalStorageDirectory())!
: await getApplicationDocumentsDirectory();
final dir = Directory('${base.path}/WebRTCRecordings');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
static Future<String> _buildOutputPath() async {
final dir = await _getRecordingDir();
final now = DateTime.now();
final name = 'rec_${now.year}${_pad(now.month)}${_pad(now.day)}_'
'${_pad(now.hour)}${_pad(now.minute)}${_pad(now.second)}.mp4';
return '${dir.path}/$name';
}
}

View File

@@ -54,8 +54,8 @@ class WebRtcOrchestrator {
/// 远程屏幕渲染器(供页面 RTCVideoView 使用)。
final RTCVideoRenderer remoteRenderer = RTCVideoRenderer();
/// 接收到的被控端报告消息(REPORT_RESOLUTION
void Function(Uint8List data)? onControlMessage;
/// 被控端上报的 REPORT_* 消息REPORT_RESOLUTION / REPORT_STREAM_MODE)。
void Function(ReportInfo report)? onReport;
/// 连接状态变化(用于 UI 显示 "已连接")。
void Function(bool connected)? onConnectionState;
@@ -74,6 +74,7 @@ class WebRtcOrchestrator {
/// 发起连接:创建 PeerConnection + DataChannel + 发送 OFFER。
Future<void> start() async {
await remoteRenderer.initialize();
await _initPeerConnection();
// 创建 control_channel有序、可靠保证指令不丢
@@ -83,11 +84,18 @@ class WebRtcOrchestrator {
_controlChannel = await _pc!.createDataChannel(_dataChannelLabel, dataChannelInit);
_controlChannel!.onMessage = (RTCDataChannelMessage message) {
if (message.isBinary) {
onControlMessage?.call(message.binary);
final report = ControlMessageDecoder.decode(message.binary);
if (report != null) {
onReport?.call(report);
}
}
};
_controlChannel!.onDataChannelState = (RTCDataChannelState state) {
debugPrint('[webrtc] control_channel state: $state');
if (state == RTCDataChannelState.RTCDataChannelOpen) {
// DataChannel 开启后发送一次默认分辨率指令,确保被控端开始推流(解决黑屏需操作才显示的 bug
sendSetResolution();
}
};
// 创建 Offer
@@ -127,22 +135,16 @@ class WebRtcOrchestrator {
debugPrint('[webrtc] 收到远程屏幕轨道');
final stream = event.streams.isNotEmpty ? event.streams.first : null;
if (stream != null) {
// RTCVideoRenderer 必须先 initialize() 再设置 srcObject否则抛
// "Call initialize before setting the stream" 异常,导致画面不渲染。
remoteRenderer.initialize().then((_) {
remoteRenderer.srcObject = stream;
}).catchError((e) {
debugPrint('[webrtc] renderer initialize error: $e');
});
remoteRenderer.srcObject = stream;
onConnectionState?.call(true);
// 媒体就绪后唤醒被控端持续推流,解决静止画面黑屏/需操作才显示。
_wakeUpStreaming();
}
};
_pc!.onConnectionState = (RTCPeerConnectionState state) {
debugPrint('[webrtc] connection state: ${state.name}');
if (state == RTCPeerConnectionState.RTCPeerConnectionStateConnected) {
onConnectionState?.call(true);
} else if (state == RTCPeerConnectionState.RTCPeerConnectionStateDisconnected ||
if (state == RTCPeerConnectionState.RTCPeerConnectionStateDisconnected ||
state == RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
state == RTCPeerConnectionState.RTCPeerConnectionStateClosed) {
onConnectionState?.call(false);
@@ -264,6 +266,31 @@ class WebRtcOrchestrator {
sendCommand(bytes);
}
/// 发送串流模式指令mode0=WebRTC 内置媒体流1=自编码 H.264)。
///
/// 注意:被控端 `stream_mode` 语义是「编码方式」而非「按需/持续推流」。
/// 本控制端未移植自编码解码链路(方案 §7.1 裁剪),因此**不要**发送
/// `mode=1`,否则被控端会切到无人消费的 `video_channel` 自编码裸流导致黑屏。
void sendSetStreamMode(int mode) {
final bytes = ControlMessageEncoder(
action: ActionType.setStreamMode,
streamMode: mode,
).encode();
sendCommand(bytes);
}
/// 媒体连接建立后唤醒被控端推帧:延迟发送一次轻量指令,确保被控端已就绪。
///
/// 静止画面持续推流由被控端自身保障(`ScreenCaptureService` 的 heartbeat
/// 每秒刷新通知扰动 MediaProjection 强制产帧),控制端无需也不应发送
/// `SET_STREAM_MODE`。这里仅请求一次默认分辨率width=0=原生)触发关键帧刷新。
void _wakeUpStreaming() {
Future.delayed(const Duration(milliseconds: 300), () {
// 请求一次默认分辨率(原生长边),触发被控端关键帧刷新远端画面。
sendSetResolution();
});
}
/// 关闭连接。
Future<void> dispose() async {
try {

View File

@@ -3,30 +3,84 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../data/connection_providers.dart';
import '../data/protobuf_codec.dart';
import '../data/video_recorder.dart';
import '../data/webrtc_orchestrator.dart';
import '../domain/connection_models.dart';
/// 分辨率预设(与被控端 SET_RESOLUTION 语义一致width<=0 表示原始)。
const List<Map<String, Object>> kResolutionOptions = [
{'label': '原始', 'width': 0, 'height': 0, 'fps': 0},
{'label': '1080P', 'width': 1920, 'height': 0, 'fps': 0},
{'label': '720P', 'width': 1280, 'height': 0, 'fps': 0},
{'label': '480P', 'width': 854, 'height': 0, 'fps': 0},
];
/// 默认帧率档位(收到被控端上报的 supported_fps 后以上报列表为准)。
const List<int> kDefaultFpsOptions = [15, 24, 30, 60];
/// 远程控制会话状态(供 UI 渲染)。
class ConnectionSessionState {
final ConnectionState state;
final String? errorMessage;
final bool hasVideo;
/// 是否正在录制远程视频。
final bool recording;
/// 录制状态提示(已保存路径等)。
final String recordStatus;
/// 当前选中的分辨率预设下标。
final int selectedResolution;
/// 分辨率预设列表。
final List<Map<String, Object>> resolutionOptions;
/// 帧率档位(收到被控端上报后以上报列表为准)。
final List<int> fpsOptions;
/// 被控端当前采集帧率0 表示尚未收到上报)。
final int currentFps;
/// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。
final int lastReportedWidth;
final int lastReportedHeight;
const ConnectionSessionState({
this.state = ConnectionState.idle,
this.errorMessage,
this.hasVideo = false,
this.recording = false,
this.recordStatus = '',
this.selectedResolution = 1,
this.resolutionOptions = kResolutionOptions,
this.fpsOptions = kDefaultFpsOptions,
this.currentFps = 0,
this.lastReportedWidth = 0,
this.lastReportedHeight = 0,
});
ConnectionSessionState copyWith({
ConnectionState? state,
String? errorMessage,
bool? hasVideo,
bool? recording,
String? recordStatus,
int? selectedResolution,
List<Map<String, Object>>? resolutionOptions,
List<int>? fpsOptions,
int? currentFps,
int? lastReportedWidth,
int? lastReportedHeight,
}) {
return ConnectionSessionState(
state: state ?? this.state,
errorMessage: errorMessage ?? this.errorMessage,
hasVideo: hasVideo ?? this.hasVideo,
recording: recording ?? this.recording,
recordStatus: recordStatus ?? this.recordStatus,
selectedResolution: selectedResolution ?? this.selectedResolution,
resolutionOptions: resolutionOptions ?? this.resolutionOptions,
fpsOptions: fpsOptions ?? this.fpsOptions,
currentFps: currentFps ?? this.currentFps,
lastReportedWidth: lastReportedWidth ?? this.lastReportedWidth,
lastReportedHeight: lastReportedHeight ?? this.lastReportedHeight,
);
}
}
@@ -34,6 +88,7 @@ class ConnectionSessionState {
/// 远程控制控制器管理连接状态机idle → connecting → connected → ended
class ConnectionController extends Notifier<ConnectionSessionState> {
WebRtcOrchestrator? _orchestrator;
final VideoRecorder _videoRecorder = VideoRecorder();
@override
ConnectionSessionState build() {
@@ -71,6 +126,9 @@ class ConnectionController extends Notifier<ConnectionSessionState> {
_orchestrator!.onConnectionState = (connected) {
_onRtcState(connected);
};
_orchestrator!.onReport = (report) {
_onReport(report);
};
signaling.onMessage = (message) {
// 先透传给 WebRTC 编排器处理ANSWER/ICE 等),确保握手能继续
@@ -104,12 +162,33 @@ class ConnectionController extends Notifier<ConnectionSessionState> {
void _onRtcState(bool connected) {
if (connected) {
state = const ConnectionSessionState(
state = state.copyWith(
state: ConnectionState.connected,
hasVideo: true,
);
} else if (state.state == ConnectionState.connected) {
state = const ConnectionSessionState(state: ConnectionState.ended);
// 连接成功后按默认分辨率1080P下发一次确保默认生效。
selectResolution(state.selectedResolution);
} else {
state = state.copyWith(
state: ConnectionState.ended,
hasVideo: false,
);
}
}
/// 处理被控端经 DataChannel 上报的 REPORT_RESOLUTION / REPORT_STREAM_MODE。
void _onReport(ReportInfo report) {
if (report.width > 0 && report.height > 0) {
state = state.copyWith(
lastReportedWidth: report.width,
lastReportedHeight: report.height,
);
}
if (report.fps > 0) {
state = state.copyWith(currentFps: report.fps);
}
if (report.supportedFps.isNotEmpty) {
state = state.copyWith(fpsOptions: report.supportedFps);
}
}
@@ -143,8 +222,68 @@ class ConnectionController extends Notifier<ConnectionSessionState> {
_orchestrator?.sendSetResolution(width: width, height: height, fps: fps);
}
/// 切换分辨率:写入状态并下发指令。
void selectResolution(int index) {
final options = state.resolutionOptions;
if (index < 0 || index >= options.length) return;
state = state.copyWith(selectedResolution: index);
final o = options[index];
_orchestrator?.sendSetResolution(
width: o['width'] as int,
height: o['height'] as int,
fps: o['fps'] as int,
);
}
/// 切换帧率:仅切帧率,分辨率保持不变。
void selectFps(int fps) {
if (fps <= 0 || fps == state.currentFps) return;
_orchestrator?.sendSetResolution(
width: state.lastReportedWidth,
height: state.lastReportedHeight,
fps: fps,
);
}
/// 切换远程视频录制:开始 / 停止。
Future<void> toggleRecord() async {
if (state.recording) {
await _stopRecording();
return;
}
await _startRecording();
}
Future<void> _startRecording() async {
if (state.recording) return;
final renderer = _orchestrator?.remoteRenderer;
final stream = renderer?.srcObject;
if (stream == null) {
state = state.copyWith(recordStatus: '尚未接收到视频画面,无法录制');
return;
}
try {
final ok = await _videoRecorder.start(stream);
if (ok) {
state = state.copyWith(recording: true, recordStatus: '录制中...');
}
} catch (e) {
state = state.copyWith(recordStatus: '');
debugPrint('[record] 开始录制失败: $e');
}
}
Future<void> _stopRecording() async {
final path = await _videoRecorder.stop();
state = state.copyWith(
recording: false,
recordStatus: path != null ? '已保存:$path' : '录制已停止',
);
}
/// 关闭并清理。
Future<void> disconnect() async {
await _videoRecorder.dispose();
await _orchestrator?.dispose();
_orchestrator = null;
ref.read(signalingClientProvider).dispose();

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -24,6 +26,10 @@ class RemoteControlPage extends ConsumerStatefulWidget {
}
class _RemoteControlPageState extends ConsumerState<RemoteControlPage> {
/// 录制状态条是否可见。
bool _showRecordStatus = false;
Timer? _recordStatusTimer;
@override
void initState() {
super.initState();
@@ -35,32 +41,85 @@ class _RemoteControlPageState extends ConsumerState<RemoteControlPage> {
});
}
@override
void dispose() {
_recordStatusTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final session = ref.watch(connectionControllerProvider);
final renderer = ref.read(connectionControllerProvider.notifier).remoteRenderer;
final connected = session.state == connection.ConnectionState.connected;
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: const Text('远程控制'),
trailing: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () {
ref.read(connectionControllerProvider.notifier).disconnect();
Navigator.of(context).maybePop();
},
child: const Text('退出'),
// 监听录制状态文字录制中一直显示非录制时更新后显示3 秒后自动隐藏。
ref.listen(
connectionControllerProvider.select((s) => s.recordStatus),
(previous, next) {
if (next.isEmpty) {
_recordStatusTimer?.cancel();
if (_showRecordStatus) {
setState(() => _showRecordStatus = false);
}
} else {
_recordStatusTimer?.cancel();
setState(() => _showRecordStatus = true);
// 录制中保持常显非录制如保存提示3 秒后自动隐藏。
final recording = ref.read(connectionControllerProvider).recording;
if (!recording) {
_recordStatusTimer = Timer(const Duration(seconds: 3), () {
if (mounted) setState(() => _showRecordStatus = false);
});
}
}
},
);
// 用 PopScope 统一处理所有退出入口(自带返回箭头 / 系统返回键):
// 无论从哪个入口离开页面,都在 pop 后断开远程连接。
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {
if (didPop) {
ref.read(connectionControllerProvider.notifier).disconnect();
}
},
child: CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: const Text('远程控制'),
trailing: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => _showMoreMenu(session),
child: const Icon(CupertinoIcons.ellipsis, size: 22),
),
),
),
child: SafeArea(
child: Column(
children: [
Expanded(
child: _buildVideo(renderer, connected, session),
),
_buildControlsBar(session),
],
child: SafeArea(
child: Stack(
children: [
Column(
children: [
Expanded(
child: _buildVideo(renderer, connected, session),
),
_buildControlsBar(session),
],
),
// 悬浮录制状态提示:叠加在视频区顶部,自适应宽度居中,不占用布局空间。
if (_showRecordStatus && session.recordStatus.isNotEmpty)
Positioned(
top: 8,
left: 0,
right: 0,
child: IgnorePointer(
child: Align(
alignment: Alignment.topCenter,
child: _buildRecordStatusBar(session),
),
),
),
],
),
),
),
);
@@ -189,6 +248,24 @@ class _RemoteControlPageState extends ConsumerState<RemoteControlPage> {
_lastMoveSample = null;
}
/// 悬浮录制状态提示条:自适应宽度居中,录制中红色、非录制灰色。
Widget _buildRecordStatusBar(ConnectionSessionState session) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: session.recording
? CupertinoColors.systemRed.withValues(alpha: 0.9)
: CupertinoColors.systemGrey.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(14),
),
child: Text(
session.recordStatus,
style: const TextStyle(fontSize: 12, color: CupertinoColors.white),
overflow: TextOverflow.ellipsis,
),
);
}
Widget _buildControlsBar(ConnectionSessionState session) {
final connected = session.state == connection.ConnectionState.connected;
return Container(
@@ -197,37 +274,199 @@ class _RemoteControlPageState extends ConsumerState<RemoteControlPage> {
color: CupertinoColors.systemGroupedBackground,
border: Border(top: BorderSide(color: CupertinoColors.separator, width: 0.5)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_CtrlButton(
icon: CupertinoIcons.speaker_slash_fill,
label: '静音',
enabled: connected,
onTap: () {},
),
_CtrlButton(
icon: CupertinoIcons.home,
label: '主页',
enabled: connected,
onTap: () => ref.read(connectionControllerProvider.notifier).sendKey(3), // KEYCODE_HOME
),
_CtrlButton(
icon: CupertinoIcons.arrow_left,
label: '返回',
enabled: connected,
onTap: () => ref.read(connectionControllerProvider.notifier).sendKey(4), // KEYCODE_BACK
),
_CtrlButton(
icon: CupertinoIcons.rectangle_grid_2x2,
label: '最近',
enabled: connected,
onTap: () => ref.read(connectionControllerProvider.notifier).sendKey(187), // KEYCODE_APP_SWITCH
// 单行:返回 / 主页 / 最近
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_CtrlButton(
icon: CupertinoIcons.arrow_left,
label: '返回',
enabled: connected,
onTap: () => ref.read(connectionControllerProvider.notifier).sendKey(4), // KEYCODE_BACK
),
_CtrlButton(
icon: CupertinoIcons.home,
label: '主页',
enabled: connected,
onTap: () => ref.read(connectionControllerProvider.notifier).sendKey(3), // KEYCODE_HOME
),
_CtrlButton(
icon: CupertinoIcons.rectangle_grid_2x2,
label: '最近',
enabled: connected,
onTap: () => ref.read(connectionControllerProvider.notifier).sendKey(187), // KEYCODE_APP_SWITCH
),
],
),
],
),
);
}
/// 弹出「更多」菜单iOS 风格 ActionSheet静音 / 分辨率 / 帧率 / 录制。
void _showMoreMenu(ConnectionSessionState session) {
final controller = ref.read(connectionControllerProvider.notifier);
final recording = session.recording;
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => CupertinoActionSheet(
title: const Text('更多操作'),
actions: [
CupertinoActionSheetAction(
onPressed: () {
Navigator.of(ctx).pop();
// TODO: 静音切换
},
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(CupertinoIcons.speaker_slash_fill,
size: 18, color: CupertinoColors.systemGrey),
SizedBox(width: 8),
Text('静音'),
],
),
),
CupertinoActionSheetAction(
onPressed: () {
Navigator.of(ctx).pop();
_showResolutionMenu(session);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(CupertinoIcons.slider_horizontal_3,
size: 18, color: CupertinoColors.systemGrey),
const SizedBox(width: 8),
Text(
session.resolutionOptions[session.selectedResolution]['label']
as String,
),
],
),
),
CupertinoActionSheetAction(
onPressed: () {
Navigator.of(ctx).pop();
_showFpsMenu(session);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(CupertinoIcons.speedometer,
size: 18, color: CupertinoColors.systemGrey),
const SizedBox(width: 8),
Text(
session.currentFps > 0 ? '${session.currentFps}fps' : '帧率',
),
],
),
),
CupertinoActionSheetAction(
isDestructiveAction: recording,
onPressed: () {
Navigator.of(ctx).pop();
controller.toggleRecord();
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
recording ? CupertinoIcons.stop_circle : CupertinoIcons.video_camera,
size: 18,
color: recording
? CupertinoColors.destructiveRed
: CupertinoColors.systemGrey,
),
const SizedBox(width: 8),
Text(recording ? '停止录制' : '录制'),
],
),
),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
),
);
}
/// 弹出分辨率选择菜单iOS 风格 ActionSheet
void _showResolutionMenu(ConnectionSessionState session) {
final controller = ref.read(connectionControllerProvider.notifier);
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => CupertinoActionSheet(
title: const Text('切换分辨率'),
actions: [
for (int i = 0; i < session.resolutionOptions.length; i++)
CupertinoActionSheetAction(
onPressed: () {
Navigator.of(ctx).pop();
controller.selectResolution(i);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (i == session.selectedResolution) ...[
const Icon(CupertinoIcons.check_mark,
size: 18, color: CupertinoColors.activeBlue),
const SizedBox(width: 8),
],
Text(session.resolutionOptions[i]['label'] as String),
],
),
),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
),
);
}
/// 弹出帧率选择菜单iOS 风格 ActionSheet仅切帧率分辨率保持不变。
void _showFpsMenu(ConnectionSessionState session) {
final controller = ref.read(connectionControllerProvider.notifier);
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => CupertinoActionSheet(
title: const Text('切换帧率'),
actions: [
for (final fps in session.fpsOptions)
CupertinoActionSheetAction(
onPressed: () {
Navigator.of(ctx).pop();
controller.selectFps(fps);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (fps == session.currentFps) ...[
const Icon(CupertinoIcons.check_mark,
size: 18, color: CupertinoColors.activeBlue),
const SizedBox(width: 8),
],
Text('${fps}fps'),
],
),
),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
),
);
}
}
class _CtrlButton extends StatelessWidget {
@@ -250,7 +489,11 @@ class _CtrlButton extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 26, color: enabled ? CupertinoColors.activeBlue : CupertinoColors.systemGrey3),
Icon(
icon,
size: 20,
color: enabled ? CupertinoColors.activeBlue : CupertinoColors.systemGrey3,
),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 12)),
],

View File

@@ -14,21 +14,21 @@ import 'location_map_view.dart';
///
/// 对应移动端设计稿的「平板信息页」:实时位置地图、最近截图、
/// 今日使用时长、设备操作(重启/关机/截屏/刷新/定位)与常用功能网格。
///
/// 性能优化说明:
/// - 首页包含百度地图(原生平台视图)与网络图片等高重绘成本内容。
/// - 为提升下拉/滚动流畅度,将数据驱动的卡片(定位卡片、截图卡片)拆分为
/// 独立的 `ConsumerWidget`,各自 `watch` 自己的 Provider实现**局部刷新**
/// 避免任意一个异步数据变化时重建整页(尤其避免重建地图原生视图)。
/// - 地图使用 `RepaintBoundary` 隔离重绘;静态卡片尽量 `const` 化。
class HomeTab extends ConsumerWidget {
const HomeTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
// 仅在此 watch 设备 SN设备切换时才重建整页结构其余异步数据由各卡片自行 watch。
final sn = ref.watch(selectedDeviceSnProvider);
final locationAsync = sn == null
? const AsyncValue<DeviceLocation?>.data(null)
: ref.watch(deviceLocationProvider(sn));
final location = locationAsync.valueOrNull;
final locating = locationAsync.isLoading;
final screenshotsAsync = sn == null
? const AsyncValue<List<ScreenshotVO>>.data([])
: ref.watch(recentScreenshotsProvider(sn));
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
@@ -84,17 +84,34 @@ class HomeTab extends ConsumerWidget {
SliverPadding(
padding: const EdgeInsets.only(bottom: 96),
sliver: SliverList(
delegate: SliverChildListDelegate([
_locationCard(l10n,
location: location,
locating: locating,
onTap: () => context.push('/map')),
_screenshotsCard(context, l10n, screenshotsAsync, ref),
_usageCard(l10n),
_deviceOpsCard(context, l10n, ref, sn),
SectionTitle(l10n.homeCommon),
_commonCard(context, ref, l10n, sn),
]),
// 用 builder 按需懒构建,避免一次性持有所有卡片 Element
// 配合下方各卡片自身的局部刷新,减少无效重建。
delegate: SliverChildBuilderDelegate(
(context, index) {
// 定位卡片、截图卡片为独立 ConsumerWidget各自 watch 自己的
// Provider实现局部刷新数据变化时不重建其余静态卡片
final child = switch (index) {
0 => const _LocationCard(),
1 => const _ScreenshotsCard(),
2 => _usageCard(l10n),
3 => _deviceOpsCard(context, l10n, ref, sn),
4 => SectionTitle(l10n.homeCommon),
5 => _commonCard(context, ref, l10n, sn),
_ => const SizedBox.shrink(),
};
// 给每个 child 加上稳定 key配合 findChildIndexCallback 实现
// 滚动时 Element 复用,避免滚动离屏/回屏时重建高成本子树。
return KeyedSubtree(
key: ValueKey<int>(index),
child: child,
);
},
childCount: 6,
findChildIndexCallback: (key) {
final v = key is ValueKey<int> ? key.value : null;
return (v != null && v < 6) ? v : null;
},
),
),
),
],
@@ -144,243 +161,6 @@ class HomeTab extends ConsumerWidget {
),
);
Widget _locationCard(
AppLocalizations l10n, {
required DeviceLocation? location,
required bool locating,
required VoidCallback onTap,
}) =>
GestureDetector(
onTap: onTap,
child: CardBox(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题栏:实时位置 + 定位状态,右上角查看详情入口
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: AppColors.green,
borderRadius: BorderRadius.circular(7),
),
child: Center(
child: Text('📍', style: const TextStyle(fontSize: 13))),
),
const SizedBox(width: 7),
Text(l10n.homeLocation,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.ink)),
const SizedBox(width: 8),
// 定位状态:放在"实时位置"标题后面
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: locating
? AppColors.surface
: AppColors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
),
child: Text(
locating
? l10n.homeLocating
: (location?.displayAddress.isNotEmpty == true
? l10n.homeLocated
: l10n.homeNoLocation),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: locating
? AppColors.sub
: (location?.displayAddress.isNotEmpty == true
? AppColors.green
: AppColors.sub),
),
),
),
const Spacer(),
// 右上角查看详情
GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.homeViewDetail,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.blue)),
const SizedBox(width: 2),
const Icon(CupertinoIcons.chevron_right,
size: 14, color: AppColors.blue),
],
),
),
],
),
),
Stack(
children: [
// 百度地图实时位置(须明确宽高,否则地图空白)
ClipRRect(
borderRadius: BorderRadius.circular(14),
child: SizedBox(
width: double.infinity,
height: 150,
child: LocationMapView(location: location),
),
),
// 地址信息浮层
// 右侧留出间隔,避免遮挡地图右侧的放大缩小按钮。
Positioned(
left: 12,
right: 52,
bottom: 10,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: CupertinoColors.white.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(10),
),
child: Text(
locating
? '📍 ${l10n.homeLocating}'
: (location?.displayAddress.isNotEmpty == true
? '📍 ${location!.displayAddress}'
: '📍 ${l10n.homeAddress}'),
style: const TextStyle(fontSize: 12, color: AppColors.ink),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
],
),
),
);
Widget _screenshotsCard(
BuildContext context,
AppLocalizations l10n,
AsyncValue<List<ScreenshotVO>> screenshots,
WidgetRef ref,
) =>
CardBox(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_cardHeader(
'🖼️',
AppColors.blue,
l10n.homeScreenshots,
trailing: l10n.homeViewAll,
onTrailingTap: (screenshots.valueOrNull?.isNotEmpty ?? false)
? () => context.push('/screenshots', extra: 0)
: null,
),
SizedBox(
height: 120,
child: screenshots.when(
loading: () => const Center(
child: CupertinoActivityIndicator(radius: 12)),
error: (e, _) => Center(
child: Text('加载失败',
style: const TextStyle(fontSize: 12, color: AppColors.sub)),
),
data: (list) {
if (list.isEmpty) {
return Center(
child: Text(l10n.homeNoScreenshot,
style: const TextStyle(fontSize: 12, color: AppColors.sub)),
);
}
// 卡片内容宽 = 屏幕宽 - 外间距(16×2) - 内边距(16×2)。
// 一排恰好 3 张:减去 2 个间距(10×2) 后均分。
const gap = 10.0;
const cardInsets = 16.0 * 2 + 16.0 * 2;
final itemWidth =
(MediaQuery.sizeOf(context).width - cardInsets - gap * 2) / 3;
return ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: list.length,
separatorBuilder: (_, _) => const SizedBox(width: gap),
itemBuilder: (_, i) {
final item = list[i];
// 统一把原始时间戳格式化为友好显示。
final time = formatTimeString(item.uploadTime);
return GestureDetector(
onTap: () => context.push('/screenshots', extra: i),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: itemWidth,
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
item.url ?? '',
fit: BoxFit.cover,
loadingBuilder: (ctx, child, progress) =>
progress == null
? child
: const Center(
child: CupertinoActivityIndicator(
radius: 10)),
errorBuilder: (ctx, err, _) => Container(
color: const Color(0xFFEEF2F7),
child: const Center(
child: Icon(CupertinoIcons.photo,
size: 22, color: AppColors.sub),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: CupertinoColors.black
.withValues(alpha: 0.45),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
),
child: Text(time,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 10,
color: CupertinoColors.white)),
),
),
],
),
),
),
);
},
);
},
),
),
],
),
);
Widget _usageCard(AppLocalizations l10n) => CardBox(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -925,3 +705,296 @@ class _MoreOpItem {
final String label;
final String op;
}
/// 实时位置卡片(独立 ConsumerWidget
///
/// 自己 `watch` 定位 Provider定位数据变化时**仅重建本卡片**,避免连带
/// 重建整页其它卡片(尤其百度地图原生视图与静态卡片),是首页流畅度的关键。
class _LocationCard extends ConsumerWidget {
const _LocationCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final sn = ref.watch(selectedDeviceSnProvider);
final locationAsync = sn == null
? const AsyncValue<DeviceLocation?>.data(null)
: ref.watch(deviceLocationProvider(sn));
final location = locationAsync.valueOrNull;
final locating = locationAsync.isLoading;
final hasAddress = location?.displayAddress.isNotEmpty == true;
return GestureDetector(
onTap: () => context.push('/map'),
child: CardBox(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题栏:实时位置 + 定位状态,右上角查看详情入口
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: AppColors.green,
borderRadius: BorderRadius.circular(7),
),
child: Center(
child: Text('📍', style: const TextStyle(fontSize: 13))),
),
const SizedBox(width: 7),
Text(l10n.homeLocation,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.ink)),
const SizedBox(width: 8),
// 定位状态:放在"实时位置"标题后面
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: locating
? AppColors.surface
: AppColors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
),
child: Text(
locating
? l10n.homeLocating
: (hasAddress ? l10n.homeLocated : l10n.homeNoLocation),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: locating
? AppColors.sub
: (hasAddress ? AppColors.green : AppColors.sub),
),
),
),
const Spacer(),
// 右上角查看详情
GestureDetector(
onTap: () => context.push('/map'),
behavior: HitTestBehavior.opaque,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.homeViewDetail,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.blue)),
const SizedBox(width: 2),
const Icon(CupertinoIcons.chevron_right,
size: 14, color: AppColors.blue),
],
),
),
],
),
),
Stack(
children: [
// 百度地图实时位置(须明确宽高,否则地图空白)。
// RepaintBoundary 隔离原生视图重绘,避免地图刷新污染父层布局。
RepaintBoundary(
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: SizedBox(
width: double.infinity,
height: 150,
child: LocationMapView(location: location),
),
),
),
// 地址信息浮层
// 右侧留出间隔,避免遮挡地图右侧的放大缩小按钮。
Positioned(
left: 12,
right: 52,
bottom: 10,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: CupertinoColors.white.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(10),
),
child: Text(
locating
? '📍 ${l10n.homeLocating}'
: (hasAddress
? '📍 ${location!.displayAddress}'
: '📍 ${l10n.homeAddress}'),
style: const TextStyle(fontSize: 12, color: AppColors.ink),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
],
),
),
);
}
}
/// 最近截图卡片(独立 ConsumerWidget
///
/// 自己 `watch` 截图 Provider截图数据变化时仅重建本卡片避免连带重建
/// 整页其它卡片。水平缩略图列表用 `RepaintBoundary` 隔离重绘。
class _ScreenshotsCard extends ConsumerWidget {
const _ScreenshotsCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final sn = ref.watch(selectedDeviceSnProvider);
final screenshots = sn == null
? const AsyncValue<List<ScreenshotVO>>.data([])
: ref.watch(recentScreenshotsProvider(sn));
return CardBox(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题栏:最近截图 + 查看全部入口
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(7),
),
child: Center(
child: Text('🖼️', style: const TextStyle(fontSize: 13))),
),
const SizedBox(width: 7),
Text(l10n.homeScreenshots,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.ink)),
const Spacer(),
if (screenshots.valueOrNull?.isNotEmpty ?? false)
GestureDetector(
onTap: () => context.push('/screenshots', extra: 0),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(l10n.homeViewAll,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.green)),
),
),
],
),
),
SizedBox(
height: 120,
child: screenshots.when(
loading: () =>
const Center(child: CupertinoActivityIndicator(radius: 12)),
error: (e, _) => Center(
child: Text('加载失败',
style: const TextStyle(fontSize: 12, color: AppColors.sub)),
),
data: (list) {
if (list.isEmpty) {
return Center(
child: Text(l10n.homeNoScreenshot,
style:
const TextStyle(fontSize: 12, color: AppColors.sub)),
);
}
// 卡片内容宽 = 屏幕宽 - 外间距(16×2) - 内边距(16×2)。
// 一排恰好 3 张:减去 2 个间距(10×2) 后均分。
const gap = 10.0;
const cardInsets = 16.0 * 2 + 16.0 * 2;
final itemWidth =
(MediaQuery.sizeOf(context).width - cardInsets - gap * 2) / 3;
return RepaintBoundary(
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: list.length,
separatorBuilder: (_, _) => const SizedBox(width: gap),
itemBuilder: (_, i) {
final item = list[i];
// 统一把原始时间戳格式化为友好显示。
final time = formatTimeString(item.uploadTime);
return GestureDetector(
onTap: () => context.push('/screenshots', extra: i),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: itemWidth,
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
item.url ?? '',
fit: BoxFit.cover,
loadingBuilder: (ctx, child, progress) =>
progress == null
? child
: const Center(
child: CupertinoActivityIndicator(
radius: 10)),
errorBuilder: (ctx, err, _) => Container(
color: const Color(0xFFEEF2F7),
child: const Center(
child: Icon(CupertinoIcons.photo,
size: 22, color: AppColors.sub),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: CupertinoColors.black
.withValues(alpha: 0.45),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
),
child: Text(time,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 10,
color: CupertinoColors.white)),
),
),
],
),
),
),
);
},
),
);
},
),
),
],
),
);
}
}

View File

@@ -26,10 +26,21 @@ class LocationMapView extends ConsumerStatefulWidget {
class _LocationMapViewState extends ConsumerState<LocationMapView> {
BMFMapController? _mapController;
bool _markerAdded = false;
bool _mapReady = false;
/// 首次创建时计算并缓存的初始地图中心,之后固定复用。
///
/// ⚠️ 关键性能点:`BMFMapWidget` 的 `mapOptions` 一旦变化会触发原生地图重建。
/// 若每次 build 都新建 `BMFMapOptions`(对象 `==` 未重写,恒为不相等),
/// 父级卡片下拉刷新导致的 rebuild 就会反复重建原生地图,造成卡顿。
/// 因此这里只在首次 build 创建一次,中心坐标的更新统一走
/// [_syncLocation]`setCenterCoordinate`),不再改 mapOptions。
late final BMFCoordinate _initialCenter;
@override
void initState() {
super.initState();
_initialCenter = _computeCenter(widget.location);
}
@override
@@ -43,7 +54,7 @@ class _LocationMapViewState extends ConsumerState<LocationMapView> {
@override
void didUpdateWidget(covariant LocationMapView oldWidget) {
super.didUpdateWidget(oldWidget);
// 定位数据变化时刷新标记位置。
// 定位数据变化时刷新标记位置(仅当地图已就绪且数据确实变化)
if (oldWidget.location != widget.location) {
_syncLocation(widget.location);
}
@@ -55,6 +66,8 @@ class _LocationMapViewState extends ConsumerState<LocationMapView> {
// 按官方「显示定位」文档:地图加载完成后再执行图层/覆盖物操作,
// 否则 addMarker / showUserLocation 等在地图未就绪时调用会不生效。
controller.setMapDidLoadCallback(callback: () {
if (_mapReady) return; // 幂等保护,避免重复注册导致重复原生调用。
_mapReady = true;
debugPrint('[LocationMapView] mapDidLoad, 执行图层与标记初始化');
// 1. 初始化定位图层 + 补偿蓝色定位点。
_enableUserLocationLayer();
@@ -113,7 +126,8 @@ class _LocationMapViewState extends ConsumerState<LocationMapView> {
if (coord == null) return;
final bd = wgs84ToBd09(coord.lat, coord.lon);
controller.setCenterCoordinate(bd, true);
// 关闭动画:避免滚动/下拉刷新期间触发原生地图的动画平移重绘,降低卡顿。
controller.setCenterCoordinate(bd, false);
if (!_markerAdded) {
// Marker 标题展示设备 SN缺失时兜底为"设备位置")。
final sn = location!.sn;
@@ -160,9 +174,11 @@ class _LocationMapViewState extends ConsumerState<LocationMapView> {
@override
Widget build(BuildContext context) {
// watch 本机定位 Provider地图可见时自动启动手机定位。
ref.watch(locationControllerProvider);
// 监听本机定位结果:成功后把蓝色定位点更新到地图。
// ⚠️ Riverpod 要求 ref.listen 只能在 build 期间调用,故放在此处。
// 使用 ref.listen 而非 ref.watch仅建立监听、**不使 build 依赖该状态**
// 因此本机定位状态变化不会触发地图原生视图AndroidView / UiKitView重建
// 这是首页下拉/滚动卡顿的关键优化。ref.listen 同样会触发 Provider 初始化以启动定位。
ref.listen<LocationState>(
locationControllerProvider,
(prev, next) {
@@ -181,7 +197,7 @@ class _LocationMapViewState extends ConsumerState<LocationMapView> {
child: BMFMapWidget(
onBMFMapCreated: _onMapCreated,
mapOptions: BMFMapOptions(
center: _computeCenter(widget.location),
center: _initialCenter, // 复用首次缓存中心,避免 rebuild 触发地图重建。
zoomLevel: 16,
showMapScaleBar: false,
),

View File

@@ -11,6 +11,7 @@ import 'app/constants/app_constants.dart';
import 'app/router/app_router.dart';
import 'core/storage/token_storage.dart';
import 'core/utils/device_info_util.dart';
import 'core/utils/display_mode_util.dart';
import 'core/utils/system_ui_util.dart';
/// 应用入口。
@@ -19,6 +20,10 @@ import 'core/utils/system_ui_util.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Android 高刷适配将屏幕切换到设备支持的最高刷新率120Hz / 144Hz
// 提升动画与滚动流畅度。iOS ProMotion 由系统自动匹配,无需处理。
await DisplayModeUtil.enableBestDisplayMode();
// 初始化百度地图 SDK需申请 AK坐标类型使用 BD09LL设备 GPS 坐标在展示前转换)。
BMFMapSDK.setAgreePrivacy(true);
BMFMapSDK.setApiKeyAndCoordType(

File diff suppressed because it is too large Load Diff

View File

@@ -97,6 +97,7 @@ dependencies:
material_ui: any
intl: any
lpinyin: ^2.0.3
flutter_displaymode: ^0.7.0
dev_dependencies:
flutter_test:
sdk: flutter