From 055b3511648db245c68618dce02f5b181d236cbf Mon Sep 17 00:00:00 2001 From: fufesou <13586388+fufesou@users.noreply.github.com> Date: Thu, 20 Feb 2025 11:53:36 +0800 Subject: [PATCH] refact: optimize, loading recent peers (#10847) Signed-off-by: fufesou --- flutter/lib/common/widgets/autocomplete.dart | 137 +++++++++++++----- .../lib/desktop/pages/connection_page.dart | 64 ++++---- flutter/lib/mobile/pages/connection_page.dart | 62 ++++---- flutter/lib/plugin/utils/dialogs.dart | 7 +- libs/hbb_common | 2 +- src/core_main.rs | 2 + src/flutter_ffi.rs | 127 +++++++++++----- src/ui/remote.rs | 3 + 8 files changed, 265 insertions(+), 139 deletions(-) diff --git a/flutter/lib/common/widgets/autocomplete.dart b/flutter/lib/common/widgets/autocomplete.dart index 978d053df..d7c713648 100644 --- a/flutter/lib/common/widgets/autocomplete.dart +++ b/flutter/lib/common/widgets/autocomplete.dart @@ -6,56 +6,123 @@ import 'package:flutter_hbb/models/peer_model.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common/widgets/peer_card.dart'; -Future> getAllPeers() async { - Map recentPeers = jsonDecode(bind.mainLoadRecentPeersSync()); - Map lanPeers = jsonDecode(bind.mainLoadLanPeersSync()); - Map combinedPeers = {}; +class AllPeersLoader { + List peers = []; + bool hasMoreRecentPeers = false; - void mergePeers(Map peers) { - if (peers.containsKey("peers")) { - dynamic peerData = peers["peers"]; + bool isPeersLoading = false; + bool _isPartialPeersLoaded = false; + bool _isPeersLoaded = false; - if (peerData is String) { - try { - peerData = jsonDecode(peerData); - } catch (e) { - print("Error decoding peers: $e"); - return; - } + AllPeersLoader(); + + bool get isLoaded => _isPartialPeersLoaded || _isPeersLoaded; + + void reset() { + peers.clear(); + hasMoreRecentPeers = false; + _isPartialPeersLoaded = false; + _isPeersLoaded = false; + } + + Future getAllPeers(void Function(VoidCallback) setState) async { + if (isPeersLoading) { + return; + } + reset(); + isPeersLoading = true; + + final startTime = DateTime.now(); + await _getAllPeers(false); + if (!hasMoreRecentPeers) { + final diffTime = DateTime.now().difference(startTime).inMilliseconds; + if (diffTime < 100) { + await Future.delayed(Duration(milliseconds: diffTime)); } + setState(() { + isPeersLoading = false; + _isPeersLoaded = true; + }); + } else { + setState(() { + _isPartialPeersLoaded = true; + }); + await _getAllPeers(true); + setState(() { + isPeersLoading = false; + _isPeersLoaded = true; + }); + } + } - if (peerData is List) { - for (var peer in peerData) { - if (peer is Map && peer.containsKey("id")) { - String id = peer["id"]; - if (!combinedPeers.containsKey(id)) { - combinedPeers[id] = peer; + Future _getAllPeers(bool getAllRecentPeers) async { + Map recentPeers = + jsonDecode(await bind.mainGetRecentPeers(getAll: getAllRecentPeers)); + Map lanPeers = jsonDecode(bind.mainLoadLanPeersSync()); + Map combinedPeers = {}; + + void mergePeers(Map peers) { + if (peers.containsKey("peers")) { + dynamic peerData = peers["peers"]; + + if (peerData is String) { + try { + peerData = jsonDecode(peerData); + } catch (e) { + print("Error decoding peers: $e"); + return; + } + } + + if (peerData is List) { + for (var peer in peerData) { + if (peer is Map && peer.containsKey("id")) { + String id = peer["id"]; + if (!combinedPeers.containsKey(id)) { + combinedPeers[id] = peer; + } } } } } } - } - mergePeers(recentPeers); - mergePeers(lanPeers); - for (var p in gFFI.abModel.allPeers()) { - if (!combinedPeers.containsKey(p.id)) { - combinedPeers[p.id] = p.toJson(); + mergePeers(recentPeers); + mergePeers(lanPeers); + for (var p in gFFI.abModel.allPeers()) { + if (!combinedPeers.containsKey(p.id)) { + combinedPeers[p.id] = p.toJson(); + } } - } - for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) { - if (!combinedPeers.containsKey(p.id)) { - combinedPeers[p.id] = p.toJson(); + for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) { + if (!combinedPeers.containsKey(p.id)) { + combinedPeers[p.id] = p.toJson(); + } } - } - List parsedPeers = []; + List parsedPeers = []; - for (var peer in combinedPeers.values) { - parsedPeers.add(Peer.fromJson(peer)); + for (var peer in combinedPeers.values) { + parsedPeers.add(Peer.fromJson(peer)); + } + + try { + final List moreRecentPeerIds = + jsonDecode(recentPeers["ids"] ?? '[]'); + hasMoreRecentPeers = false; + for (final id in moreRecentPeerIds) { + final sid = id.toString(); + if (!parsedPeers.any((element) => element.id == sid)) { + parsedPeers.add(Peer.fromJson({'id': sid})); + hasMoreRecentPeers = true; + } + } + } catch (e) { + debugPrint("Error parsing more peer ids: $e"); + } + + peers = parsedPeers; } - return parsedPeers; } class AutocompletePeerTile extends StatefulWidget { diff --git a/flutter/lib/desktop/pages/connection_page.dart b/flutter/lib/desktop/pages/connection_page.dart index d9dc3eec4..131337165 100644 --- a/flutter/lib/desktop/pages/connection_page.dart +++ b/flutter/lib/desktop/pages/connection_page.dart @@ -200,18 +200,20 @@ class _ConnectionPageState extends State final _idController = IDTextEditingController(); final RxBool _idInputFocused = false.obs; + final FocusNode _idFocusNode = FocusNode(); + final TextEditingController _idEditingController = TextEditingController(); bool isWindowMinimized = false; - List peers = []; - bool isPeersLoading = false; - bool isPeersLoaded = false; + AllPeersLoader allPeersLoader = AllPeersLoader(); + // https://github.com/flutter/flutter/issues/157244 Iterable _autocompleteOpts = []; @override void initState() { super.initState(); + _idFocusNode.addListener(onFocusChanged); if (_idController.text.isEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) async { final lastRemoteId = await bind.mainGetLastRemoteId(); @@ -230,6 +232,9 @@ class _ConnectionPageState extends State void dispose() { _idController.dispose(); windowManager.removeListener(this); + _idFocusNode.removeListener(onFocusChanged); + _idFocusNode.dispose(); + _idEditingController.dispose(); if (Get.isRegistered()) { Get.delete(); } @@ -273,6 +278,13 @@ class _ConnectionPageState extends State bind.mainOnMainWindowClose(); } + void onFocusChanged() { + _idInputFocused.value = _idFocusNode.hasFocus; + if (_idFocusNode.hasFocus && !allPeersLoader.isPeersLoading) { + allPeersLoader.getAllPeers(setState); + } + } + @override Widget build(BuildContext context) { final isOutgoingOnly = bind.isOutgoingOnly(); @@ -304,18 +316,6 @@ class _ConnectionPageState extends State connect(context, id, isFileTransfer: isFileTransfer); } - Future _fetchPeers() async { - setState(() { - isPeersLoading = true; - }); - await Future.delayed(Duration(milliseconds: 100)); - peers = await getAllPeers(); - setState(() { - isPeersLoading = false; - isPeersLoaded = true; - }); - } - /// UI for the remote ID TextField. /// Search for a peer. Widget _buildRemoteIDTextField(BuildContext context) { @@ -332,11 +332,12 @@ class _ConnectionPageState extends State Row( children: [ Expanded( - child: Autocomplete( + child: RawAutocomplete( optionsBuilder: (TextEditingValue textEditingValue) { if (textEditingValue.text == '') { _autocompleteOpts = const Iterable.empty(); - } else if (peers.isEmpty && !isPeersLoaded) { + } else if (allPeersLoader.peers.isEmpty && + !allPeersLoader.isLoaded) { Peer emptyPeer = Peer( id: '', username: '', @@ -363,7 +364,7 @@ class _ConnectionPageState extends State ); } String textToFind = textEditingValue.text.toLowerCase(); - _autocompleteOpts = peers + _autocompleteOpts = allPeersLoader.peers .where((peer) => peer.id.toLowerCase().contains(textToFind) || peer.username @@ -377,6 +378,8 @@ class _ConnectionPageState extends State } return _autocompleteOpts; }, + focusNode: _idFocusNode, + textEditingController: _idEditingController, fieldViewBuilder: ( BuildContext context, TextEditingController fieldTextEditingController, @@ -385,17 +388,17 @@ class _ConnectionPageState extends State ) { fieldTextEditingController.text = _idController.text; Get.put(fieldTextEditingController); - fieldFocusNode.addListener(() async { - _idInputFocused.value = fieldFocusNode.hasFocus; - if (fieldFocusNode.hasFocus && !isPeersLoading) { - _fetchPeers(); - } - }); - final textLength = - fieldTextEditingController.value.text.length; - // select all to facilitate removing text, just following the behavior of address input of chrome - fieldTextEditingController.selection = - TextSelection(baseOffset: 0, extentOffset: textLength); + + // The listener will be added multiple times when the widget is rebuilt. + // We may need to use the `RawAutocomplete` to get the focus node. + + // Temporarily remove Selection because Selection can cause users to accidentally delete previously entered content during input. + // final textLength = + // fieldTextEditingController.value.text.length; + // // Select all to facilitate removing text, just following the behavior of address input of chrome. + // fieldTextEditingController.selection = + // TextSelection(baseOffset: 0, extentOffset: textLength); + return Obx(() => TextField( autocorrect: false, enableSuggestions: false, @@ -468,7 +471,8 @@ class _ConnectionPageState extends State maxHeight: maxHeight, maxWidth: 319, ), - child: peers.isEmpty && isPeersLoading + child: allPeersLoader.peers.isEmpty && + !allPeersLoader.isLoaded ? Container( height: 80, child: Center( diff --git a/flutter/lib/mobile/pages/connection_page.dart b/flutter/lib/mobile/pages/connection_page.dart index 295310338..54056a00d 100644 --- a/flutter/lib/mobile/pages/connection_page.dart +++ b/flutter/lib/mobile/pages/connection_page.dart @@ -41,10 +41,11 @@ class _ConnectionPageState extends State { final _idController = IDTextEditingController(); final RxBool _idEmpty = true.obs; - List peers = []; + final FocusNode _idFocusNode = FocusNode(); + final TextEditingController _idEditingController = TextEditingController(); + + AllPeersLoader allPeersLoader = AllPeersLoader(); - bool isPeersLoading = false; - bool isPeersLoaded = false; StreamSubscription? _uniLinksSubscription; // https://github.com/flutter/flutter/issues/157244 @@ -61,6 +62,7 @@ class _ConnectionPageState extends State { @override void initState() { super.initState(); + _idFocusNode.addListener(onFocusChanged); if (_idController.text.isEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) async { final lastRemoteId = await bind.mainGetLastRemoteId(); @@ -99,6 +101,13 @@ class _ConnectionPageState extends State { connect(context, id); } + void onFocusChanged() { + _idEmpty.value = _idEditingController.text.isEmpty; + if (_idFocusNode.hasFocus && !allPeersLoader.isPeersLoading) { + allPeersLoader.getAllPeers(setState); + } + } + /// UI for software update. /// If _updateUrl] is not empty, shows a button to update the software. Widget _buildUpdateUI(String updateUrl) { @@ -127,18 +136,6 @@ class _ConnectionPageState extends State { color: Colors.white, fontWeight: FontWeight.bold)))); } - Future _fetchPeers() async { - setState(() { - isPeersLoading = true; - }); - await Future.delayed(Duration(milliseconds: 100)); - peers = await getAllPeers(); - setState(() { - isPeersLoading = false; - isPeersLoaded = true; - }); - } - /// UI for the remote ID TextField. /// Search for a peer and connect to it if the id exists. Widget _buildRemoteIDTextField() { @@ -156,11 +153,12 @@ class _ConnectionPageState extends State { Expanded( child: Container( padding: const EdgeInsets.only(left: 16, right: 16), - child: Autocomplete( + child: RawAutocomplete( optionsBuilder: (TextEditingValue textEditingValue) { if (textEditingValue.text == '') { _autocompleteOpts = const Iterable.empty(); - } else if (peers.isEmpty && !isPeersLoaded) { + } else if (allPeersLoader.peers.isEmpty && + !allPeersLoader.isLoaded) { Peer emptyPeer = Peer( id: '', username: '', @@ -188,7 +186,7 @@ class _ConnectionPageState extends State { } String textToFind = textEditingValue.text.toLowerCase(); - _autocompleteOpts = peers + _autocompleteOpts = allPeersLoader.peers .where((peer) => peer.id.toLowerCase().contains(textToFind) || peer.username @@ -202,6 +200,8 @@ class _ConnectionPageState extends State { } return _autocompleteOpts; }, + focusNode: _idFocusNode, + textEditingController: _idEditingController, fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, @@ -209,18 +209,14 @@ class _ConnectionPageState extends State { fieldTextEditingController.text = _idController.text; Get.put( fieldTextEditingController); - fieldFocusNode.addListener(() async { - _idEmpty.value = - fieldTextEditingController.text.isEmpty; - if (fieldFocusNode.hasFocus && !isPeersLoading) { - _fetchPeers(); - } - }); - final textLength = - fieldTextEditingController.value.text.length; - // select all to facilitate removing text, just following the behavior of address input of chrome - fieldTextEditingController.selection = TextSelection( - baseOffset: 0, extentOffset: textLength); + + // Temporarily remove Selection because Selection can cause users to accidentally delete previously entered content during input. + // final textLength = + // fieldTextEditingController.value.text.length; + // // select all to facilitate removing text, just following the behavior of address input of chrome + // fieldTextEditingController.selection = TextSelection( + // baseOffset: 0, extentOffset: textLength); + return AutoSizeTextField( controller: fieldTextEditingController, focusNode: fieldFocusNode, @@ -300,7 +296,8 @@ class _ConnectionPageState extends State { maxHeight: maxHeight, maxWidth: 320, ), - child: peers.isEmpty && isPeersLoading + child: allPeersLoader.peers.isEmpty && + !allPeersLoader.isLoaded ? Container( height: 80, child: Center( @@ -363,6 +360,9 @@ class _ConnectionPageState extends State { void dispose() { _uniLinksSubscription?.cancel(); _idController.dispose(); + _idFocusNode.removeListener(onFocusChanged); + _idFocusNode.dispose(); + _idEditingController.dispose(); if (Get.isRegistered()) { Get.delete(); } diff --git a/flutter/lib/plugin/utils/dialogs.dart b/flutter/lib/plugin/utils/dialogs.dart index f30248f7a..c01dc368f 100644 --- a/flutter/lib/plugin/utils/dialogs.dart +++ b/flutter/lib/plugin/utils/dialogs.dart @@ -6,12 +6,13 @@ import 'package:flutter_hbb/models/platform_model.dart'; void showPeerSelectionDialog( {bool singleSelection = false, - required Function(List) onPeersCallback}) { - final peers = bind.mainLoadRecentPeersSync(); + required Function(List) onPeersCallback}) async { + final peers = await bind.mainGetRecentPeers(getAll: true); if (peers.isEmpty) { - debugPrint("load recent peers sync failed."); + debugPrint("load recent peers failed."); return; } + Map map = jsonDecode(peers); List peersList = map['peers'] ?? []; final selected = List.empty(growable: true); diff --git a/libs/hbb_common b/libs/hbb_common index e7d210e03..f94753bdd 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit e7d210e03a7a7b1ba10018f4c9829f07ca7ea51b +Subproject commit f94753bddb11a6de52e4fe7f9e490fcf8df2275a diff --git a/src/core_main.rs b/src/core_main.rs index 9745a32e3..182a04a16 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -169,6 +169,8 @@ pub fn core_main() -> Option> { #[cfg(not(any(target_os = "android", target_os = "ios")))] init_plugins(&args); if args.is_empty() || crate::common::is_empty_uni_link(&args[0]) { + #[cfg(windows)] + hbb_common::config::PeerConfig::preload_peers(); std::thread::spawn(move || crate::start_server(false, no_server)); } else { #[cfg(windows)] diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 5c1925dfd..1a65658e2 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -1101,44 +1101,99 @@ pub fn main_peer_exists(id: String) -> bool { peer_exists(&id) } +fn load_recent_peers( + vec_id_modified_time_path: &Vec<(String, SystemTime, std::path::PathBuf)>, + to_end: bool, + all_peers: &mut Vec>, + from: usize, +) -> usize { + let to = if to_end { + Some(vec_id_modified_time_path.len()) + } else { + None + }; + let mut peers_next = PeerConfig::batch_peers(vec_id_modified_time_path, from, to); + // There may be less peers than the batch size. + // But no need to consider this case, because it is a rare case. + let peers = peers_next.0.drain(..).map(|(id, _, p)| peer_to_map(id, p)); + all_peers.extend(peers); + peers_next.1 +} + pub fn main_load_recent_peers() { if !config::APP_DIR.read().unwrap().is_empty() { - let peers: Vec> = PeerConfig::peers(None) - .drain(..) - .map(|(id, _, p)| peer_to_map(id, p)) - .collect(); + let vec_id_modified_time_path = PeerConfig::get_vec_id_modified_time_path(&None); + if vec_id_modified_time_path.is_empty() { + return; + } - let data = HashMap::from([ - ("name", "load_recent_peers".to_owned()), - ( - "peers", - serde_json::ser::to_string(&peers).unwrap_or("".to_owned()), - ), - ]); - let _res = flutter::push_global_event( - flutter::APP_TYPE_MAIN, - serde_json::ser::to_string(&data).unwrap_or("".to_owned()), - ); + let push_to_flutter = |peers| { + let data = HashMap::from([("name", "load_recent_peers".to_owned()), ("peers", peers)]); + let _res = flutter::push_global_event( + flutter::APP_TYPE_MAIN, + serde_json::ser::to_string(&data).unwrap_or("".to_owned()), + ); + }; + + let load_two_times = + vec_id_modified_time_path.len() > PeerConfig::BATCH_LOADING_COUNT && cfg!(target_os = "windows"); + let mut all_peers = vec![]; + if load_two_times { + let next_from = load_recent_peers(&vec_id_modified_time_path, false, &mut all_peers, 0); + push_to_flutter(serde_json::ser::to_string(&all_peers).unwrap_or("".to_owned())); + let _ = load_recent_peers(&vec_id_modified_time_path, true, &mut all_peers, next_from); + } else { + let _ = load_recent_peers(&vec_id_modified_time_path, true, &mut all_peers, 0); + } + push_to_flutter(serde_json::ser::to_string(&all_peers).unwrap_or("".to_owned())); } } -pub fn main_load_recent_peers_sync() -> SyncReturn { - if !config::APP_DIR.read().unwrap().is_empty() { - let peers: Vec> = PeerConfig::peers(None) - .drain(..) - .map(|(id, _, p)| peer_to_map(id, p)) +fn get_partial_recent_peers( + vec_id_modified_time_path: Vec<(String, SystemTime, std::path::PathBuf)>, + count: usize, +) -> String { + let (peers, next_from) = PeerConfig::batch_peers( + &vec_id_modified_time_path, + 0, + Some(count.min(vec_id_modified_time_path.len())), + ); + let peer_maps: Vec<_> = peers + .into_iter() + .map(|(id, _, p)| peer_to_map(id, p)) + .collect(); + let mut data = HashMap::from([( + "peers", + serde_json::ser::to_string(&peer_maps).unwrap_or("[]".to_owned()), + )]); + if next_from < vec_id_modified_time_path.len() { + let ids: Vec<_> = vec_id_modified_time_path[next_from..] + .iter() + .map(|(id, _, _)| id.clone()) .collect(); - - let data = HashMap::from([ - ("name", "load_recent_peers".to_owned()), - ( - "peers", - serde_json::ser::to_string(&peers).unwrap_or("".to_owned()), - ), - ]); - return SyncReturn(serde_json::ser::to_string(&data).unwrap_or("".to_owned())); + data.insert( + "ids", + serde_json::ser::to_string(&ids).unwrap_or("[]".to_owned()), + ); } - SyncReturn("".to_string()) + return serde_json::ser::to_string(&data).unwrap_or("".to_owned()); +} + +pub fn main_get_recent_peers(get_all: bool) -> String { + if !config::APP_DIR.read().unwrap().is_empty() { + let vec_id_modified_time_path = PeerConfig::get_vec_id_modified_time_path(&None); + + let load_two_times = !get_all + && vec_id_modified_time_path.len() > PeerConfig::BATCH_LOADING_COUNT + && cfg!(target_os = "windows"); + let load_count = if load_two_times { + PeerConfig::BATCH_LOADING_COUNT + } else { + vec_id_modified_time_path.len() + }; + return get_partial_recent_peers(vec_id_modified_time_path, load_count); + } + "".to_string() } pub fn main_load_lan_peers_sync() -> SyncReturn { @@ -1172,11 +1227,11 @@ pub fn main_load_recent_peers_for_ab(filter: String) -> String { pub fn main_load_fav_peers() { if !config::APP_DIR.read().unwrap().is_empty() { let favs = get_fav(); - let mut recent = PeerConfig::peers(None); + let mut recent = PeerConfig::peers(Some(favs.clone())); let mut lan = config::LanPeers::load() .peers .iter() - .filter(|d| recent.iter().all(|r| r.0 != d.id)) + .filter(|d| favs.contains(&d.id) && recent.iter().all(|r| r.0 != d.id)) .map(|d| { ( d.id.clone(), @@ -1195,13 +1250,7 @@ pub fn main_load_fav_peers() { recent.append(&mut lan); let peers: Vec> = recent .into_iter() - .filter_map(|(id, _, p)| { - if favs.contains(&id) { - Some(peer_to_map(id, p)) - } else { - None - } - }) + .map(|(id, _, p)| peer_to_map(id, p)) .collect(); let data = HashMap::from([ diff --git a/src/ui/remote.rs b/src/ui/remote.rs index d57da2267..8bcc9cc1b 100644 --- a/src/ui/remote.rs +++ b/src/ui/remote.rs @@ -319,6 +319,9 @@ impl InvokeUiSession for SciterHandler { ConnType::DEFAULT_CONN => { crate::keyboard::client::start_grab_loop(); } + // Left empty code from compilation. + // Please replace the code in the PR. + ConnType::VIEW_CAMERA => {} } }