CW-1024 Improve performance of xmr wallet (#2128)

* - enabled development options in CI builds. - Implemented caching for account retrieval. - refactor transaction handling in `dashboard_view_model.dart` to improve efficiency and reduce unnecessary updates in xmr. - `DevMoneroCallProfilerPage`, for profiling performance of xmr,wow,zano wallet calls. * use FeatureFlag.hasDevOptions * prevent crashes in monero_c by using mutexes properly improve performance of _transactionDisposer remove unnecessary checks * remove logging, bring back simplified logic * update _transactionDisposer on length and confirmation of first and last transaction * address comments from review * don't throw unhandled exceptions in unawaited async code * use cached transaction list in getAllSubaddresses, fix usage of txHistoryMutex * [DNM] fix: crashes when opening wallet, performance issue when syncing and update dependencies * Revert "use cached transaction list in getAllSubaddresses, fix usage of txHistoryMutex" This reverts commit 4c4c33ac6a47603e970a6c8d940e90204525b241. * Revert "[DNM] fix: crashes when opening wallet, performance issue when syncing and update dependencies" This reverts commit d7603445ad6ae76d76bf179c34728ce242c8c610. * Revert "use cached transaction list in getAllSubaddresses, fix usage of txHistoryMutex" This reverts commit 4c4c33ac6a47603e970a6c8d940e90204525b241. * update shared_preferences * improve state management performance by not rendering multiple changes in transaction screen on a single frame * fix wallet switching

cyan committed Apr 3, 2025 at 03:31 UTC cbca4c9c77ec59dcc328834215c075f99f4c7312
25 files changed +498 -105
.github/workflows/pr_test_build_android.yml
+1 -1
@@ -274,7 +274,7 @@ jobs:
274
275 - name: Build
276 run: |
277 - flutter build apk --release --split-per-abi
277 + flutter build apk --dart-define=hasDevOptions=true --release --split-per-abi
278
279 - name: Rename apk file
280 run: |
.github/workflows/pr_test_build_linux.yml
+1 -1
@@ -225,7 +225,7 @@ jobs:
225
226 - name: Build linux
227 run: |
228 - flutter build linux --release
228 + flutter build linux --dart-define=hasDevOptions=true --release
229
230 - name: Compress release
231 run: |
cw_monero/lib/api/coins_info.dart
+40 -9
@@ -1,21 +1,42 @@
1 +import 'dart:ffi';
2 +import 'dart:isolate';
3 +
4 import 'package:cw_monero/api/account_list.dart';
5 import 'package:monero/monero.dart' as monero;
6 +import 'package:mutex/mutex.dart';
7
8 monero.Coins? coins = null;
9 +final coinsMutex = Mutex();
10
6 -void refreshCoins(int accountIndex) {
11 +Future<void> refreshCoins(int accountIndex) async {
12 + if (coinsMutex.isLocked) {
13 + return;
14 + }
15 coins = monero.Wallet_coins(wptr!);
8 - monero.Coins_refresh(coins!);
16 + final coinsPtr = coins!.address;
17 + await coinsMutex.acquire();
18 + await Isolate.run(() => monero.Coins_refresh(Pointer.fromAddress(coinsPtr)));
19 + coinsMutex.release();
20 }
21
11 -int countOfCoins() => monero.Coins_count(coins!);
22 +Future<int> countOfCoins() async {
23 + await coinsMutex.acquire();
24 + final count = monero.Coins_count(coins!);
25 + coinsMutex.release();
26 + return count;
27 +}
28
13 -monero.CoinsInfo getCoin(int index) => monero.Coins_coin(coins!, index);
29 +Future<monero.CoinsInfo> getCoin(int index) async {
30 + await coinsMutex.acquire();
31 + final coin = monero.Coins_coin(coins!, index);
32 + coinsMutex.release();
33 + return coin;
34 +}
35
15 -int? getCoinByKeyImage(String keyImage) {
16 - final count = countOfCoins();
36 +Future<int?> getCoinByKeyImage(String keyImage) async {
37 + final count = await countOfCoins();
38 for (int i = 0; i < count; i++) {
18 - final coin = getCoin(i);
39 + final coin = await getCoin(i);
40 final coinAddress = monero.CoinsInfo_keyImage(coin);
41 if (keyImage == coinAddress) {
42 return i;
@@ -24,6 +45,16 @@ int? getCoinByKeyImage(String keyImage) {
45 return null;
46 }
47
27 -void freezeCoin(int index) => monero.Coins_setFrozen(coins!, index: index);
48 +Future<void> freezeCoin(int index) async {
49 + await coinsMutex.acquire();
50 + final coinsPtr = coins!.address;
51 + await Isolate.run(() => monero.Coins_setFrozen(Pointer.fromAddress(coinsPtr), index: index));
52 + coinsMutex.release();
53 +}
54
29 -void thawCoin(int index) => monero.Coins_thaw(coins!, index: index);
55 +Future<void> thawCoin(int index) async {
56 + await coinsMutex.acquire();
57 + final coinsPtr = coins!.address;
58 + await Isolate.run(() => monero.Coins_thaw(Pointer.fromAddress(coinsPtr), index: index));
59 + coinsMutex.release();
60 +}
cw_monero/lib/api/transaction_history.dart
+40 -4
@@ -1,6 +1,7 @@
1 import 'dart:ffi';
2 import 'dart:isolate';
3
4 +import 'package:cw_core/utils/print_verbose.dart';
5 import 'package:cw_monero/api/account_list.dart';
6 import 'package:cw_monero/api/exceptions/creation_transaction_exception.dart';
7 import 'package:cw_monero/api/monero_output.dart';
@@ -13,15 +14,23 @@ import 'package:monero/src/generated_bindings_monero.g.dart' as monero_gen;
14 import 'package:mutex/mutex.dart';
15
16
17 +Map<int, Map<String, String>> txKeys = {};
18 String getTxKey(String txId) {
19 + txKeys[wptr!.address] ??= {};
20 + if (txKeys[wptr!.address]![txId] != null) {
21 + return txKeys[wptr!.address]![txId]!;
22 + }
23 final txKey = monero.Wallet_getTxKey(wptr!, txid: txId);
24 final status = monero.Wallet_status(wptr!);
25 if (status != 0) {
20 - final error = monero.Wallet_errorString(wptr!);
26 + monero.Wallet_errorString(wptr!);
27 + txKeys[wptr!.address]![txId] = "";
28 return "";
29 }
30 + txKeys[wptr!.address]![txId] = txKey;
31 return txKey;
32 }
33 +
34 final txHistoryMutex = Mutex();
35 monero.TransactionHistory? txhistory;
36 bool isRefreshingTx = false;
@@ -34,6 +43,7 @@ Future<void> refreshTransactions() async {
43 await Isolate.run(() {
44 monero.TransactionHistory_refresh(Pointer.fromAddress(ptr));
45 });
46 + await Future.delayed(Duration.zero);
47 txHistoryMutex.release();
48 isRefreshingTx = false;
49 }
@@ -45,8 +55,24 @@ Future<List<Transaction>> getAllTransactions() async {
55
56 await txHistoryMutex.acquire();
57 txhistory ??= monero.Wallet_history(wptr!);
58 + final startAddress = txhistory!.address * wptr!.address;
59 int size = countOfTransactions();
49 - final list = List.generate(size, (index) => Transaction(txInfo: monero.TransactionHistory_transaction(txhistory!, index: index)));
60 + final list = <Transaction>[];
61 + for (int index = 0; index < size; index++) {
62 + if (index % 25 == 0) {
63 + // Give main thread a chance to do other things.
64 + await Future.delayed(Duration.zero);
65 + }
66 + if (txhistory!.address * wptr!.address != startAddress) {
67 + printV("Loop broken because txhistory!.address * wptr!.address != startAddress");
68 + break;
69 + }
70 + final txInfo = monero.TransactionHistory_transaction(txhistory!, index: index);
71 + final txHash = monero.TransactionInfo_hash(txInfo);
72 + txCache[wptr!.address] ??= {};
73 + txCache[wptr!.address]![txHash] = Transaction(txInfo: txInfo);
74 + list.add(txCache[wptr!.address]![txHash]!);
75 + }
76 txHistoryMutex.release();
77 final accts = monero.Wallet_numSubaddressAccounts(wptr!);
78 for (var i = 0; i < accts; i++) {
@@ -79,8 +105,18 @@ Future<List<Transaction>> getAllTransactions() async {
105 return list;
106 }
107
82 -Transaction getTransaction(String txId) {
83 - return Transaction(txInfo: monero.TransactionHistory_transactionById(txhistory!, txid: txId));
108 +Map<int, Map<String, Transaction>> txCache = {};
109 +Future<Transaction> getTransaction(String txId) async {
110 + if (txCache[wptr!.address] != null && txCache[wptr!.address]![txId] != null) {
111 + return txCache[wptr!.address]![txId]!;
112 + }
113 + await txHistoryMutex.acquire();
114 + final tx = monero.TransactionHistory_transactionById(txhistory!, txid: txId);
115 + final txDart = Transaction(txInfo: tx);
116 + txCache[wptr!.address] ??= {};
117 + txCache[wptr!.address]![txId] = txDart;
118 + txHistoryMutex.release();
119 + return txDart;
120 }
121
122 Future<PendingTransactionDescription> createTransactionSync(
cw_monero/lib/api/wallet.dart
+12 -6
@@ -6,6 +6,7 @@ import 'package:cw_core/root_dir.dart';
6 import 'package:cw_core/utils/print_verbose.dart';
7 import 'package:cw_monero/api/account_list.dart';
8 import 'package:cw_monero/api/exceptions/setup_wallet_exception.dart';
9 +import 'package:cw_monero/api/wallet_manager.dart';
10 import 'package:flutter/foundation.dart';
11 import 'package:monero/monero.dart' as monero;
12 import 'package:mutex/mutex.dart';
@@ -199,12 +200,15 @@ void startRefreshSync() {
200 }
201
202
202 -void setRefreshFromBlockHeight({required int height}) =>
203 - monero.Wallet_setRefreshFromBlockHeight(wptr!,
204 - refresh_from_block_height: height);
203 +void setRefreshFromBlockHeight({required int height}) {
204 + monero.Wallet_setRefreshFromBlockHeight(wptr!,
205 + refresh_from_block_height: height);
206 +}
207
206 -void setRecoveringFromSeed({required bool isRecovery}) =>
207 - monero.Wallet_setRecoveringFromSeed(wptr!, recoveringFromSeed: isRecovery);
208 +void setRecoveringFromSeed({required bool isRecovery}) {
209 + monero.Wallet_setRecoveringFromSeed(wptr!, recoveringFromSeed: isRecovery);
210 + monero.Wallet_store(wptr!);
211 +}
212
213 final storeMutex = Mutex();
214
@@ -394,4 +398,6 @@ String signMessage(String message, {String address = ""}) {
398
399 bool verifyMessage(String message, String address, String signature) {
400 return monero.Wallet_verifySignedMessage(wptr!, message: message, address: address, signature: signature);
397 -}
\ No newline at end of file
401 +}
402 +
403 +Map<String, List<int>> debugCallLength() => monero.debugCallLength;
cw_monero/lib/api/wallet_manager.dart
+1
@@ -137,6 +137,7 @@ void restoreWalletFromSeedSync(
137 wptr = newWptr;
138
139 setRefreshFromBlockHeight(height: restoreHeight);
140 + setupBackgroundSync(password, newWptr);
141
142 monero.Wallet_setCacheAttribute(wptr!, key: "cakewallet.passphrase", value: passphrase);
143
cw_monero/lib/monero_account_list.dart
+16 -1
@@ -1,5 +1,6 @@
1 import 'package:cw_core/monero_amount_format.dart';
2 import 'package:cw_core/utils/print_verbose.dart';
3 +import 'package:cw_monero/api/wallet_manager.dart';
4 import 'package:mobx/mobx.dart';
5 import 'package:cw_core/account.dart';
6 import 'package:cw_monero/api/account_list.dart' as account_list;
@@ -44,7 +45,18 @@ abstract class MoneroAccountListBase with Store {
45 }
46 }
47
47 - List<Account> getAll() => account_list.getAllAccount().map((accountRow) {
48 + Map<int, List<Account>> _cachedAccounts = {};
49 +
50 + List<Account> getAll() {
51 + final allAccounts = account_list.getAllAccount();
52 + final currentCount = allAccounts.length;
53 + _cachedAccounts[account_list.wptr!.address] ??= [];
54 +
55 + if (_cachedAccounts[account_list.wptr!.address]!.length == currentCount) {
56 + return _cachedAccounts[account_list.wptr!.address]!;
57 + }
58 +
59 + _cachedAccounts[account_list.wptr!.address] = allAccounts.map((accountRow) {
60 final balance = monero.SubaddressAccountRow_getUnlockedBalance(accountRow);
61
62 return Account(
@@ -53,6 +65,9 @@ abstract class MoneroAccountListBase with Store {
65 balance: moneroAmountToString(amount: monero.Wallet_amountFromString(balance)),
66 );
67 }).toList();
68 +
69 + return _cachedAccounts[account_list.wptr!.address]!;
70 + }
71
72 Future<void> addAccount({required String label}) async {
73 await account_list.addAccount(label: label);
cw_monero/lib/monero_unspent.dart
+19 -14
@@ -7,28 +7,33 @@ class MoneroUnspent extends Unspent {
7 MoneroUnspent(
8 String address, String hash, String keyImage, int value, bool isFrozen, this.isUnlocked)
9 : super(address, hash, value, 0, keyImage) {
10 + getCoinByKeyImage(keyImage).then((coinId) {
11 + if (coinId == null) return;
12 + getCoin(coinId).then((coin) {
13 + _frozen = monero.CoinsInfo_frozen(coin);
14 + });
15 + });
16 }
17
18 + bool _frozen = false;
19 +
20 @override
21 set isFrozen(bool freeze) {
22 printV("set isFrozen: $freeze ($keyImage): $freeze");
15 - final coinId = getCoinByKeyImage(keyImage!);
16 - if (coinId == null) throw Exception("Unable to find a coin for address $address");
17 - if (freeze) {
18 - freezeCoin(coinId);
19 - } else {
20 - thawCoin(coinId);
21 - }
23 + getCoinByKeyImage(keyImage!).then((coinId) async {
24 + if (coinId == null) return;
25 + if (freeze) {
26 + await freezeCoin(coinId);
27 + _frozen = true;
28 + } else {
29 + await thawCoin(coinId);
30 + _frozen = false;
31 + }
32 + });
33 }
34
35 @override
25 - bool get isFrozen {
26 - printV("get isFrozen");
27 - final coinId = getCoinByKeyImage(keyImage!);
28 - if (coinId == null) throw Exception("Unable to find a coin for address $address");
29 - final coin = getCoin(coinId);
30 - return monero.CoinsInfo_frozen(coin);
31 - }
36 + bool get isFrozen => _frozen;
37
38 final bool isUnlocked;
39 }
cw_monero/lib/monero_wallet.dart
+21 -7
@@ -169,6 +169,7 @@ abstract class MoneroWalletBase extends WalletBase<MoneroBalance,
169 if (monero_wallet.getCurrentHeight() <= 1) {
170 monero_wallet.setRefreshFromBlockHeight(
171 height: walletInfo.restoreHeight);
172 + setupBackgroundSync(password, wptr!);
173 }
174 }
175
@@ -570,6 +571,7 @@ abstract class MoneroWalletBase extends WalletBase<MoneroBalance,
571 walletInfo.restoreHeight = height;
572 walletInfo.isRecovery = true;
573 monero_wallet.setRefreshFromBlockHeight(height: height);
574 + setupBackgroundSync(password, wptr!);
575 monero_wallet.rescanBlockchainAsync();
576 await startSync();
577 _askForUpdateBalance();
@@ -585,9 +587,9 @@ abstract class MoneroWalletBase extends WalletBase<MoneroBalance,
587
588 unspentCoins.clear();
589
588 - final coinCount = countOfCoins();
590 + final coinCount = await countOfCoins();
591 for (var i = 0; i < coinCount; i++) {
590 - final coin = getCoin(i);
592 + final coin = await getCoin(i);
593 final coinSpent = monero.CoinsInfo_spent(coin);
594 if (coinSpent == false && monero.CoinsInfo_subaddrAccount(coin) == walletAddresses.account!.id) {
595 final unspent = MoneroUnspent(
@@ -600,7 +602,8 @@ abstract class MoneroWalletBase extends WalletBase<MoneroBalance,
602 );
603 // TODO: double-check the logic here
604 if (unspent.hash.isNotEmpty) {
603 - unspent.isChange = transaction_history.getTransaction(unspent.hash).isSpend == true;
605 + final tx = await transaction_history.getTransaction(unspent.hash);
606 + unspent.isChange = tx.isSpend == true;
607 }
608 unspentCoins.add(unspent);
609 }
@@ -692,14 +695,15 @@ abstract class MoneroWalletBase extends WalletBase<MoneroBalance,
695
696 @override
697 Future<Map<String, MoneroTransactionInfo>> fetchTransactions() async {
695 - transaction_history.refreshTransactions();
696 - return (await _getAllTransactionsOfAccount(walletAddresses.account?.id))
698 + await transaction_history.refreshTransactions();
699 + final resp = (await _getAllTransactionsOfAccount(walletAddresses.account?.id))
700 .fold<Map<String, MoneroTransactionInfo>>(
701 <String, MoneroTransactionInfo>{},
702 (Map<String, MoneroTransactionInfo> acc, MoneroTransactionInfo tx) {
703 acc[tx.id] = tx;
704 return acc;
705 });
706 + return resp;
707 }
708
709 Future<void> updateTransactions() async {
@@ -710,8 +714,17 @@ abstract class MoneroWalletBase extends WalletBase<MoneroBalance,
714
715 _isTransactionUpdating = true;
716 final transactions = await fetchTransactions();
713 - transactionHistory.clear();
714 - transactionHistory.addMany(transactions);
717 +
718 + final currentIds = transactionHistory.transactions.keys.toSet();
719 + final newIds = transactions.keys.toSet();
720 +
721 + // Remove transactions that no longer exist
722 + currentIds.difference(newIds).forEach((id) =>
723 + transactionHistory.transactions.remove(id));
724 +
725 + // Add or update transactions
726 + transactions.forEach((key, tx) =>
727 + transactionHistory.transactions[key] = tx);
728 await transactionHistory.save();
729 _isTransactionUpdating = false;
730 } catch (e) {
@@ -778,6 +791,7 @@ abstract class MoneroWalletBase extends WalletBase<MoneroBalance,
791
792 monero_wallet.setRecoveringFromSeed(isRecovery: true);
793 monero_wallet.setRefreshFromBlockHeight(height: height);
794 + setupBackgroundSync(password, wptr!);
795 }
796
797 int _getHeightDistance(DateTime date) {
cw_monero/lib/monero_wallet_service.dart
-7
@@ -159,19 +159,12 @@ class MoneroWalletService extends WalletService<
159 walletInfo: walletInfo,
160 unspentCoinsInfo: unspentCoinsInfoSource,
161 password: password);
162 - final isValid = wallet.walletAddresses.validate();
162
163 if (wallet.isHardwareWallet) {
164 wallet.setLedgerConnection(gLedger!);
165 gLedger = null;
166 }
167
169 - if (!isValid) {
170 - await restoreOrResetWalletFiles(name);
171 - wallet.close(shouldCleanup: false);
172 - return openWallet(name, password);
173 - }
174 -
168 await wallet.init();
169
170 return wallet;
cw_wownero/lib/api/wallet.dart
+2
@@ -354,3 +354,5 @@ String signMessage(String message, {String address = ""}) {
354 bool verifyMessage(String message, String address, String signature) {
355 return wownero.Wallet_verifySignedMessage(wptr!, message: message, address: address, signature: signature);
356 }
357 +
358 +Map<String, List<int>> debugCallLength() => wownero.debugCallLength;
\ No newline at end of file
cw_zano/lib/zano_wallet_api.dart
+3 -1
@@ -508,4 +508,6 @@ Future<String> _closeWallet(int hWallet) async {
508 });
509 printV("Closing wallet: $str");
510 return str;
511 -}
\ No newline at end of file
511 +}
512 +
513 +Map<String, List<int>> debugCallLength() => zano.debugCallLength;
\ No newline at end of file
devtools_options.yaml deleted
-1
@@ -1 +0,0 @@
1 -extensions:
lib/di.dart
+2
@@ -35,6 +35,7 @@ import 'package:cake_wallet/entities/hardware_wallet/require_hardware_wallet_con
35 import 'package:cake_wallet/entities/parse_address_from_domain.dart';
36 import 'package:cake_wallet/exchange/provider/trocador_exchange_provider.dart';
37 import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart';
38 +import 'package:cake_wallet/src/screens/dev/moneroc_call_profiler.dart';
39 import 'package:cake_wallet/src/screens/settings/background_sync_page.dart';
40 import 'package:cake_wallet/view_model/dev/monero_background_sync.dart';
41 import 'package:cake_wallet/view_model/link_view_model.dart';
@@ -1450,5 +1451,6 @@ Future<void> setup({
1451 getIt.registerFactory(() => SeedVerificationPage(getIt.get<WalletSeedViewModel>()));
1452
1453 getIt.registerFactory(() => DevMoneroBackgroundSyncPage(getIt.get<DevMoneroBackgroundSync>()));
1454 + getIt.registerFactory(() => DevMoneroCallProfilerPage());
1455 _isSetupFinished = true;
1456 }
lib/monero/cw_monero.dart
+6
@@ -424,4 +424,10 @@ class CWMonero extends Monero {
424 bool isViewOnly() {
425 return isViewOnlyBySpendKey(null);
426 }
427 +
428 + @override
429 + Map<String, List<int>> debugCallLength() {
430 + return monero_wallet_api.debugCallLength();
431 + }
432 +
433 }
lib/router.dart
+6
@@ -37,6 +37,7 @@ import 'package:cake_wallet/src/screens/dashboard/pages/nft_details_page.dart';
37 import 'package:cake_wallet/src/screens/dashboard/pages/transactions_page.dart';
38 import 'package:cake_wallet/src/screens/dashboard/sign_page.dart';
39 import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart';
40 +import 'package:cake_wallet/src/screens/dev/moneroc_call_profiler.dart';
41 import 'package:cake_wallet/src/screens/disclaimer/disclaimer_page.dart';
42 import 'package:cake_wallet/src/screens/exchange/exchange_page.dart';
43 import 'package:cake_wallet/src/screens/exchange/exchange_template_page.dart';
@@ -841,6 +842,11 @@ Route<dynamic> createRoute(RouteSettings settings) {
842 builder: (_) => getIt.get<DevMoneroBackgroundSyncPage>(),
843 );
844
845 + case Routes.devMoneroCallProfiler:
846 + return MaterialPageRoute<void>(
847 + builder: (_) => getIt.get<DevMoneroCallProfilerPage>(),
848 + );
849 +
850 default:
851 return MaterialPageRoute<void>(
852 builder: (_) => Scaffold(
lib/routes.dart
+1 -1
@@ -112,7 +112,7 @@ class Routes {
112 static const torPage = '/tor_page';
113 static const backgroundSync = '/background_sync';
114 static const devMoneroBackgroundSync = '/dev/monero_background_sync';
115 -
115 + static const devMoneroCallProfiler = '/dev/monero_call_profiler';
116 static const signPage = '/sign_page';
117 static const connectDevices = '/device/connect';
118 static const urqrAnimatedPage = '/urqr/animated_page';
lib/src/screens/dev/moneroc_call_profiler.dart new
+253
@@ -0,0 +1,253 @@
1 +// code shamelessly stolen from xmruw
2 +// https://raw.githubusercontent.com/MrCyjaneK/unnamed_monero_wallet/refs/heads/master-rewrite/lib/pages/debug/performance.dart
3 +import 'dart:math';
4 +
5 +import 'package:cake_wallet/di.dart';
6 +import 'package:cake_wallet/monero/monero.dart';
7 +import 'package:cake_wallet/src/widgets/primary_button.dart';
8 +import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
9 +import 'package:cake_wallet/wownero/wownero.dart';
10 +import 'package:cake_wallet/zano/zano.dart';
11 +import 'package:cw_core/wallet_type.dart';
12 +import 'package:flutter/material.dart';
13 +import 'package:cake_wallet/src/screens/base_page.dart';
14 +
15 +class DevMoneroCallProfilerPage extends BasePage {
16 + DevMoneroCallProfilerPage();
17 +
18 + @override
19 + String? get title => "[dev] xmr call profiler";
20 +
21 + @override
22 + Widget body(BuildContext context) {
23 + return PerformanceDebug();
24 + }
25 +}
26 +
27 +
28 +
29 +class PerformanceDebug extends StatefulWidget {
30 + const PerformanceDebug({super.key});
31 +
32 + @override
33 + State<PerformanceDebug> createState() => _PerformanceDebugState();
34 +}
35 +
36 +enum ProfilableWallet {
37 + monero,
38 + wownero,
39 + zano,
40 +}
41 +
42 +class _PerformanceDebugState extends State<PerformanceDebug> {
43 + List<Widget> widgets = [];
44 +
45 + final dashboardViewModel = getIt.get<DashboardViewModel>();
46 +
47 + late ProfilableWallet wallet = switch (dashboardViewModel.wallet.type) {
48 + WalletType.monero => ProfilableWallet.monero,
49 + WalletType.wownero => ProfilableWallet.wownero,
50 + WalletType.zano => ProfilableWallet.zano,
51 + _ => throw Exception("Unknown wallet type"),
52 + };
53 + final precalc = 1700298;
54 +
55 + late Map<String, List<int>> debugCallLength = switch (wallet) {
56 + ProfilableWallet.monero => monero!.debugCallLength(),
57 + ProfilableWallet.wownero => wownero!.debugCallLength(),
58 + ProfilableWallet.zano => zano!.debugCallLength(),
59 + };
60 +
61 + int getOpenWalletTime() {
62 + if (debugCallLength["MONERO_Wallet_init"] == null) {
63 + return precalc;
64 + }
65 + if (debugCallLength["MONERO_Wallet_init"]!.isEmpty) {
66 + return precalc;
67 + }
68 + return debugCallLength["MONERO_Wallet_init"]!.last;
69 + }
70 +
71 +late final String perfInfo = """
72 +---- Performance tuning
73 +This page lists all calls that take place during the app runtime.-
74 +As per Flutter docs we can read:
75 +> Flutter aims to provide 60 frames per second (fps) performance, or 120 fps-
76 +performance on devices capable of 120Hz updates.
77 +
78 +With that in mind we will aim to render frames every 8.3ms (~8333 µs). It is-
79 +however acceptable to reach 16.6 ms (~16666 µs) but we should also keep in mind-
80 +that there are also UI costs that aren't part of this benchmark.
81 +
82 +For some calls it is also acceptable to exceed this amount of time, for example-
83 +MONERO_Wallet_init takes ~${getOpenWalletTime()}µs-
84 +(${(getOpenWalletTime() / frameTime).toStringAsFixed(2)} frames). That time would-
85 +be unnaceptable in most situations but since we call this function only when-
86 +opening the wallet it is completely fine to freeze the UI for the time being --
87 +as the user won't even notice that something happened.
88 +
89 +---- Details
90 +count: how many times did we call this function [total time (% of frame)]
91 +average: average execution time (% of frame)
92 +min: fastest execution (% of frame)
93 +max: slowest execution (% of frame)
94 +95th: 95% of the time, the function is faster than this amount of time (% of frame)
95 +"""
96 + .split("-\n")
97 + .join(" ");
98 +
99 + late final frameTime = 8333;
100 + late final frameGreenTier = frameTime ~/ 100;
101 + late final frameBlueTier = frameTime ~/ 10;
102 + late final frameBlueGreyTier = frameTime ~/ 2;
103 + late final frameYellowTier = frameTime;
104 + late final frameOrangeTier = frameTime * 2;
105 +
106 + Color? perfc(num frame) {
107 + if (frame < frameGreenTier) return Colors.green;
108 + if (frame < frameBlueTier) return Colors.blue;
109 + if (frame < frameBlueGreyTier) return Colors.blueGrey;
110 + if (frame < frameGreenTier) return Colors.green;
111 + if (frame < frameYellowTier) return Colors.yellow;
112 + if (frame < frameOrangeTier) return Colors.orange;
113 + return Colors.red;
114 + }
115 +
116 +
117 + @override
118 + void initState() {
119 + _buildWidgets();
120 + super.initState();
121 + }
122 +
123 + SelectableText cw(String text, Color? color) {
124 + return SelectableText(
125 + text,
126 + style: TextStyle(color: color),
127 + );
128 + }
129 +
130 + void _buildWidgets() {
131 + List<Widget> ws = [];
132 + ws.add(Column(
133 + mainAxisSize: MainAxisSize.min,
134 + mainAxisAlignment: MainAxisAlignment.start,
135 + crossAxisAlignment: CrossAxisAlignment.start,
136 + children: [
137 + SelectableText(perfInfo),
138 + cw("< 1% of a frame (max: $frameGreenTierµs)", Colors.green),
139 + cw("< 10% of a frame (max: $frameBlueTierµs)", Colors.blue),
140 + cw("< 50% of a frame (max: $frameBlueGreyTierµs)", Colors.blueGrey),
141 + cw("< 100% of a frame (max: $frameYellowTierµs)", Colors.yellow),
142 + cw("< 200% of a frame (max: $frameOrangeTierµs)", Colors.orange),
143 + cw("> 200% of a frame (UI junk visible)", Colors.red),
144 + ],
145 + ));
146 + final keys = debugCallLength.keys.toList();
147 + keys.sort((s1, s2) =>
148 + _n95th(debugCallLength[s2]!) -
149 + _n95th(debugCallLength[s1]!));
150 + for (var key in keys) {
151 + final value = debugCallLength[key];
152 + if (value == null) continue;
153 + final avg = _avg(value);
154 + final min = _min(value);
155 + final max = _max(value);
156 + final np = _n95th(value);
157 + final total = _total(value);
158 + ws.add(
159 + Card(
160 + child: ListTile(
161 + title: Text(
162 + key,
163 + style: TextStyle(color: perfc(np)),
164 + ),
165 + subtitle: Column(
166 + mainAxisSize: MainAxisSize.min,
167 + mainAxisAlignment: MainAxisAlignment.start,
168 + crossAxisAlignment: CrossAxisAlignment.start,
169 + children: [
170 + Row(children: [
171 + cw("count: ${value.length}", null),
172 + const Spacer(),
173 + cw("${_str(total / 1000)}ms", perfc(total)),
174 + ]),
175 + cw("average: ${_str(avg)}µs (~${_str(avg / (frameTime))}f)",
176 + perfc(avg)),
177 + cw("min: $minµs (~${_str(min / (frameTime) * 100)})",
178 + perfc(min)),
179 + cw("max: $maxµs (~${_str(max / (frameTime) * 100)}%)",
180 + perfc(max)),
181 + cw("95th: $npµs (~${_str(np / (frameTime) * 100)}%)",
182 + perfc(np)),
183 + ],
184 + ),
185 + ),
186 + ),
187 + );
188 + }
189 + if (debugCallLength.isNotEmpty) {
190 + ws.add(
191 + PrimaryButton(
192 + text: "Purge statistics",
193 + onPressed: _purgeStats,
194 + color: Colors.red,
195 + textColor: Colors.white,
196 + ),
197 + );
198 + }
199 + setState(() {
200 + widgets = ws;
201 + });
202 + }
203 +
204 + void _purgeStats() {
205 + debugCallLength.clear();
206 + _buildWidgets();
207 + }
208 +
209 + int _min(List<int> l) {
210 + return l.reduce(min);
211 + }
212 +
213 + int _max(List<int> l) {
214 + return l.reduce(max);
215 + }
216 +
217 + int _n95th(List<int> l) {
218 + final l0 = l.toList();
219 + l0.sort();
220 + int i = (0.95 * l.length).ceil() - 1;
221 + return l0[i];
222 + }
223 +
224 + double _avg(List<int> l) {
225 + int c = 0;
226 + for (var i = 0; i < l.length; i++) {
227 + c += l[i];
228 + }
229 + return c / l.length;
230 + }
231 +
232 + int _total(List<int> l) {
233 + int c = 0;
234 + for (var i = 0; i < l.length; i++) {
235 + c += l[i];
236 + }
237 + return c;
238 + }
239 +
240 + String _str(num d) => d.toStringAsFixed(2);
241 +
242 + @override
243 + Widget build(BuildContext context) {
244 + return SingleChildScrollView(
245 + child: Padding(
246 + padding: const EdgeInsets.all(8),
247 + child: Column(
248 + children: widgets,
249 + ),
250 + ),
251 + );
252 + }
253 +}
lib/src/screens/settings/other_settings_page.dart
+8 -1
@@ -8,6 +8,7 @@ import 'package:cake_wallet/src/screens/settings/widgets/settings_cell_with_arro
8 import 'package:cake_wallet/src/screens/settings/widgets/settings_picker_cell.dart';
9 import 'package:cake_wallet/src/screens/settings/widgets/settings_switcher_cell.dart';
10 import 'package:cake_wallet/src/screens/settings/widgets/settings_version_cell.dart';
11 +import 'package:cake_wallet/utils/feature_flag.dart';
12 import 'package:cake_wallet/view_model/settings/other_settings_view_model.dart';
13 import 'package:cw_core/wallet_type.dart';
14 import 'package:flutter/foundation.dart';
@@ -64,12 +65,18 @@ class OtherSettingsPage extends BasePage {
65 handler: (BuildContext context) =>
66 Navigator.of(context).pushNamed(Routes.readDisclaimer),
67 ),
67 - if (kDebugMode && _otherSettingsViewModel.walletType == WalletType.monero)
68 + if (FeatureFlag.hasDevOptions && _otherSettingsViewModel.walletType == WalletType.monero)
69 SettingsCellWithArrow(
70 title: '[dev] monero background sync',
71 handler: (BuildContext context) =>
72 Navigator.of(context).pushNamed(Routes.devMoneroBackgroundSync),
73 ),
74 + if (FeatureFlag.hasDevOptions && [WalletType.monero, WalletType.wownero, WalletType.zano].contains(_otherSettingsViewModel.walletType))
75 + SettingsCellWithArrow(
76 + title: '[dev] xmr call profiler',
77 + handler: (BuildContext context) =>
78 + Navigator.of(context).pushNamed(Routes.devMoneroCallProfiler),
79 + ),
80 Spacer(),
81 SettingsVersionCell(
82 title: S.of(context).version(_otherSettingsViewModel.currentVersion)),
lib/utils/feature_flag.dart
+1
@@ -6,4 +6,5 @@ class FeatureFlag {
6 static const bool isInAppTorEnabled = false;
7 static const bool isBackgroundSyncEnabled = true;
8 static const int verificationWordsCount = kDebugMode ? 0 : 2;
9 + static const bool hasDevOptions = bool.fromEnvironment('hasDevOptions', defaultValue: kDebugMode);
10 }
\ No newline at end of file
lib/view_model/dashboard/dashboard_view_model.dart
+50 -49
@@ -271,32 +271,10 @@ abstract class DashboardViewModelBase with Store {
271 });
272
273 _transactionDisposer?.reaction.dispose();
274 -
274 _transactionDisposer = reaction(
276 - (_) => appStore.wallet!.transactionHistory.transactions.values.toList(),
277 - (List<TransactionInfo> txs) {
278 -
279 - transactions.clear();
280 -
281 - transactions.addAll(
282 - txs.where((tx) {
283 - if (wallet.type == WalletType.monero) {
284 - return monero!.getTransactionInfoAccountId(tx) == monero!.getCurrentAccount(wallet).id;
285 - }
286 - if (wallet.type == WalletType.wownero) {
287 - return wow.wownero!.getTransactionInfoAccountId(tx) == wow.wownero!.getCurrentAccount(wallet).id;
288 - }
289 - return true;
290 - }).map(
291 - (tx) => TransactionListItem(
292 - transaction: tx,
293 - balanceViewModel: balanceViewModel,
294 - settingsStore: appStore.settingsStore,
295 - key: ValueKey('${wallet.type.name}_transaction_history_item_${tx.id}_key'),
296 - ),
297 - ),
298 - );
299 - }
275 + (_) => appStore.wallet!.transactionHistory.transactions.length *
276 + appStore.wallet!.transactionHistory.transactions.values.first.confirmations,
277 + _transactionDisposerCallback
278 );
279
280 if (hasSilentPayments) {
@@ -311,6 +289,50 @@ abstract class DashboardViewModelBase with Store {
289 reaction((_) => settingsStore.mwebAlwaysScan, (bool value) => _checkMweb());
290 }
291
292 +
293 + bool _isTransactionDisposerCallbackRunning = false;
294 +
295 + void _transactionDisposerCallback(int _) async {
296 + // Simple check to prevent the callback from being called multiple times in the same frame
297 + if (_isTransactionDisposerCallbackRunning) return;
298 + _isTransactionDisposerCallbackRunning = true;
299 + await Future.delayed(Duration.zero);
300 +
301 +
302 + try {
303 + final currentAccountId = wallet.type == WalletType.monero
304 + ? monero!.getCurrentAccount(wallet).id
305 + : wallet.type == WalletType.wownero
306 + ? wow.wownero!.getCurrentAccount(wallet).id
307 + : null;
308 + final List<TransactionInfo> relevantTxs = [];
309 +
310 + for (final tx in appStore.wallet!.transactionHistory.transactions.values) {
311 + bool isRelevant = true;
312 + if (wallet.type == WalletType.monero) {
313 + isRelevant = monero!.getTransactionInfoAccountId(tx) == currentAccountId;
314 + } else if (wallet.type == WalletType.wownero) {
315 + isRelevant = wow.wownero!.getTransactionInfoAccountId(tx) == currentAccountId;
316 + }
317 +
318 + if (isRelevant) {
319 + relevantTxs.add(tx);
320 + }
321 + }
322 + // printV("Transaction disposer callback (relevantTxs: ${relevantTxs.length} current: ${transactions.length})");
323 +
324 + transactions.clear();
325 + transactions.addAll(relevantTxs.map((tx) => TransactionListItem(
326 + transaction: tx,
327 + balanceViewModel: balanceViewModel,
328 + settingsStore: appStore.settingsStore,
329 + key: ValueKey('${wallet.type.name}_transaction_history_item_${tx.id}_key'),
330 + )));
331 + } finally {
332 + _isTransactionDisposerCallbackRunning = false;
333 + }
334 + }
335 +
336 void _checkMweb() {
337 if (hasMweb) {
338 mwebEnabled = bitcoin!.getMwebEnabled(wallet);
@@ -789,30 +811,9 @@ abstract class DashboardViewModelBase with Store {
811 _transactionDisposer?.reaction.dispose();
812
813 _transactionDisposer = reaction(
792 - (_) => appStore.wallet!.transactionHistory.transactions.values.toList(),
793 - (List<TransactionInfo> txs) {
794 -
795 - transactions.clear();
796 -
797 - transactions.addAll(
798 - txs.where((tx) {
799 - if (wallet.type == WalletType.monero) {
800 - return monero!.getTransactionInfoAccountId(tx) == monero!.getCurrentAccount(wallet).id;
801 - }
802 - if (wallet.type == WalletType.wownero) {
803 - return wow.wownero!.getTransactionInfoAccountId(tx) == wow.wownero!.getCurrentAccount(wallet).id;
804 - }
805 - return true;
806 - }).map(
807 - (tx) => TransactionListItem(
808 - transaction: tx,
809 - balanceViewModel: balanceViewModel,
810 - settingsStore: appStore.settingsStore,
811 - key: ValueKey('${wallet.type.name}_transaction_history_item_${tx.id}_key'),
812 - ),
813 - ),
814 - );
815 - }
814 + (_) => appStore.wallet!.transactionHistory.transactions.length *
815 + appStore.wallet!.transactionHistory.transactions.values.first.confirmations,
816 + _transactionDisposerCallback
817 );
818 }
819
lib/wownero/cw_wownero.dart
+5
@@ -361,4 +361,9 @@ class CWWownero extends Wownero {
361 void wownerocCheck() {
362 checkIfMoneroCIsFine();
363 }
364 +
365 + @override
366 + Map<String, List<int>> debugCallLength() {
367 + return wownero_wallet_api.debugCallLength();
368 + }
369 }
lib/zano/cw_zano.dart
+5
@@ -131,4 +131,9 @@ class CWZano extends Zano {
131
132 @override
133 bool validateAddress(String address) => ZanoUtils.validateAddress(address);
134 +
135 + @override
136 + Map<String, List<int>> debugCallLength() {
137 + return api.debugCallLength();
138 + }
139 }
pubspec_base.yaml
+1 -2
@@ -10,7 +10,7 @@ dependencies:
10 url: https://github.com/cake-tech/qr.flutter.git
11 ref: cake-4.0.2
12 version: 4.0.2
13 - shared_preferences: 2.3.2
13 + shared_preferences: 2.5.3
14 # provider: ^6.0.3
15 rxdart: ^0.28.0
16 yaml: ^3.1.1
@@ -83,7 +83,6 @@ dependencies:
83 version: 1.0.0
84 flutter_plugin_android_lifecycle: 2.0.23
85 path_provider_android: ^2.2.1
86 - shared_preferences_android: 2.3.3
86 url_launcher_android: 6.3.14
87 url_launcher_linux: 3.1.1 # https://github.com/flutter/flutter/issues/153083
88 sensitive_clipboard:
tool/configure.dart
+4
@@ -425,6 +425,7 @@ abstract class Monero {
425 void setLedgerConnection(Object wallet, ledger.LedgerConnection connection);
426 void resetLedgerConnection();
427 void setGlobalLedgerConnection(ledger.LedgerConnection connection);
428 + Map<String, List<int>> debugCallLength();
429 }
430
431 abstract class MoneroSubaddressList {
@@ -610,6 +611,7 @@ abstract class Wownero {
611 WalletService createWowneroWalletService(Box<WalletInfo> walletInfoSource, Box<UnspentCoinsInfo> unspentCoinSource);
612 Map<String, String> pendingTransactionInfo(Object transaction);
613 String getLegacySeed(Object wallet, String langName);
614 + Map<String, List<int>> debugCallLength();
615 }
616
617 abstract class WowneroSubaddressList {
@@ -1253,6 +1255,7 @@ import 'package:cw_zano/model/zano_transaction_info.dart';
1255 import 'package:cw_zano/zano_formatter.dart';
1256 import 'package:cw_zano/zano_wallet.dart';
1257 import 'package:cw_zano/zano_wallet_service.dart';
1258 +import 'package:cw_zano/zano_wallet_api.dart' as api;
1259 import 'package:cw_zano/zano_utils.dart';
1260 """;
1261 const zanoCwPart = "part 'cw_zano.dart';";
@@ -1279,6 +1282,7 @@ abstract class Zano {
1282 Future<CryptoCurrency?> getZanoAsset(WalletBase wallet, String contractAddress);
1283 String getAddress(WalletBase wallet);
1284 bool validateAddress(String address);
1285 + Map<String, List<int>> debugCallLength();
1286 }
1287 """;
1288 const zanoEmptyDefinition = 'Zano? zano;\n';