perf(android): keep Core calls and events off the main thread

Changelog: Smoother Android UI while the proxy is busy
This commit is contained in:
chen08209
2026-10-14 09:34:47 +08:00
parent 5984c161c5
commit 2201c199b8
9 changed files with 114 additions and 74 deletions
@@ -64,7 +64,7 @@ object ServiceController {
}
}
fun setEventListener(callback: ((String?) -> Unit)?): Result<Unit> = runCatching {
fun setEventListener(callback: ((ByteArray) -> Unit)?): Result<Unit> = runCatching {
Core.updateEventListener(callback)
}
@@ -123,8 +123,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
}
override fun onMethodCall(call: MethodCall, rawResult: Result) {
val result = MainThreadResult(rawResult)
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"moveTaskToBack" -> {
activity?.moveTaskToBack(true)
@@ -1,37 +0,0 @@
package com.follow.clash.plugins
import android.os.Handler
import android.os.Looper
import io.flutter.plugin.common.MethodChannel
/**
* Delivers a [MethodChannel.Result] on the main thread, which is where Flutter
* requires it, from handlers that finish on a background dispatcher.
*
* The hop goes through the main looper rather than the plugin's coroutine scope:
* that scope is cancelled when the engine detaches, and a reply dropped by a
* cancelled scope leaves the Dart future waiting forever. A reply posted after
* the engine is gone is the messenger's problem to ignore, and it does.
*/
internal class MainThreadResult(
private val delegate: MethodChannel.Result,
) : MethodChannel.Result {
override fun success(result: Any?) = post { delegate.success(result) }
override fun error(code: String, message: String?, details: Any?) =
post { delegate.error(code, message, details) }
override fun notImplemented() = post { delegate.notImplemented() }
private fun post(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
mainHandler.post(block)
}
}
private companion object {
val mainHandler = Handler(Looper.getMainLooper())
}
}
@@ -6,8 +6,10 @@ import com.follow.clash.common.Components
import com.follow.clash.models.SharedState
import com.google.gson.Gson
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.StandardMethodCodec
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -15,32 +17,39 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var messenger: BinaryMessenger
private lateinit var channel: MethodChannel
private lateinit var coreChannel: MethodChannel
private lateinit var scope: CoroutineScope
private val gson = Gson()
private var commandRevision = 0
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
channel = MethodChannel(binding.binaryMessenger, "${Components.PACKAGE_NAME}/service")
messenger = binding.binaryMessenger
channel = MethodChannel(messenger, SERVICE_CHANNEL)
channel.setMethodCallHandler(this)
// Serial: watchRoute keeps the latest intent by the order calls reach the Core.
coreChannel = MethodChannel(
messenger,
CORE_CHANNEL,
StandardMethodCodec.INSTANCE,
messenger.makeBackgroundTaskQueue(),
)
coreChannel.setMethodCallHandler(::onCoreMethodCall)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
coreChannel.setMethodCallHandler(null)
scope.cancel()
ServiceController.setEventListener(null)
}
override fun onMethodCall(call: MethodCall, rawResult: MethodChannel.Result) {
// Most handlers below reply from a scope worker on Dispatchers.Default,
// but a MethodChannel.Result has to be answered on the platform thread.
// Wrapping once here covers every branch, including notImplemented.
val result = MainThreadResult(rawResult)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"init" -> initialize(result)
"shutdown" -> shutdown(result)
"invokeMethod" -> invokeMethod(call, result)
"getRunTime" -> getRunTime(result)
"syncState" -> syncState(call, result)
"start" -> start(call, result)
@@ -49,6 +58,13 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
}
private fun onCoreMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"invokeMethod" -> invokeMethod(call, result)
else -> result.notImplemented()
}
}
private fun initialize(result: MethodChannel.Result) {
ServiceController.setEventListener(::sendEvent)
.onSuccess { result.success("") }
@@ -66,9 +82,6 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
result.error("INVALID_ARGUMENT", "Method call payload must be a string", null)
return
}
// Inline rather than a coroutine per call, which could swap two: watchRoute
// keeps the latest intent by the order calls reach the Core
// (dispatchMethodCall in core/method.go), and this only hands data over.
ServiceController.invokeMethod(data) { response ->
result.success(response)
}.onFailure { error ->
@@ -118,9 +131,15 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
}
private fun sendEvent(value: String?) {
private fun sendEvent(value: ByteArray) {
val message = StandardMethodCodec.INSTANCE.encodeMethodCall(MethodCall("event", value))
scope.launch(Dispatchers.Main) {
channel.invokeMethod("event", value)
messenger.send(SERVICE_CHANNEL, message)
}
}
private companion object {
val SERVICE_CHANNEL = "${Components.PACKAGE_NAME}/service"
val CORE_CHANNEL = "${Components.PACKAGE_NAME}/core"
}
}
@@ -67,10 +67,10 @@ object Core {
}
fun updateEventListener(
callback: ((result: String?) -> Unit)?,
callback: ((result: ByteArray) -> Unit)?,
) {
Mobile.setEventListener(
callback?.let { ResultHandler { result -> it(result?.decodeToString()) } },
callback?.let { ResultHandler { result -> it(result ?: ByteArray(0)) } },
)
}
@@ -10,6 +10,7 @@ import android.net.NetworkCapabilities.TRANSPORT_USB
import android.net.NetworkRequest
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
import androidx.core.content.getSystemService
import com.follow.clash.core.Core
@@ -32,7 +33,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
private val connectivity by lazy {
service.getSystemService<ConnectivityManager>()
}
private val mainHandler = Handler(Looper.getMainLooper())
private val handler = Handler(networkObserveLooper)
private var currentDnsList = listOf<String>()
private var currentNetwork: Network? = null
@@ -56,7 +57,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
info.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
scheduleUpdateDns()
if (maxMsToLive > 0) {
mainHandler.postDelayed({
handler.postDelayed({
if (networkInfos.containsKey(network)) {
updateDns()
}
@@ -80,8 +81,8 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
// A switch arrives as a burst, and a network that just came up has no DNS
// until its link properties follow; the Core flushes its cache per list.
private fun scheduleUpdateDns() {
mainHandler.removeCallbacks(updateDnsTask)
mainHandler.postDelayed(updateDnsTask, DNS_UPDATE_DELAY_MILLIS)
handler.removeCallbacks(updateDnsTask)
handler.postDelayed(updateDnsTask, DNS_UPDATE_DELAY_MILLIS)
}
override fun start() {
@@ -131,7 +132,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
}
override fun stop() {
mainHandler.removeCallbacksAndMessages(null)
handler.removeCallbacksAndMessages(null)
try {
connectivity?.unregisterNetworkCallback(callback)
} finally {
@@ -145,6 +146,10 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
private const val DNS_PORT = 53
private const val DNS_UPDATE_DELAY_MILLIS = 200L
private val networkObserveLooper: Looper by lazy {
HandlerThread("NetworkObserve").apply { start() }.looper
}
private fun InetAddress.asSocketAddressText(port: Int): String = when (this) {
is Inet6Address -> "[$hostAddress]:$port"
is Inet4Address -> "$hostAddress:$port"
+11 -1
View File
@@ -374,9 +374,19 @@ func StopListener() {
}
}
var forceGCPending atomic.Bool
// Android calls this from onLowMemory and onTrimMemory on the main thread, which
// the Flutter UI shares, and a full collection of a large profile spans frames.
func ForceGC() {
defer recoverExport("forceGC")
handleForceGC()
if !forceGCPending.CompareAndSwap(false, true) {
return
}
safeGo("forceGC", func() {
defer forceGCPending.Store(false)
handleForceGC()
})
}
func UpdateDns(value string) {
+9 -4
View File
@@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/event.dart';
import 'package:fl_clash/core/method.dart';
import 'package:fl_clash/core/spill.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter/foundation.dart';
@@ -29,6 +30,7 @@ abstract mixin class ServiceListener {
class Service {
static Service? _instance;
late MethodChannel methodChannel;
static const _coreChannel = MethodChannel('$packageName/core');
final ObserverList<ServiceListener> _listeners =
ObserverList<ServiceListener>();
@@ -46,9 +48,10 @@ class Service {
methodChannel.setMethodCallHandler((call) async {
switch (call.method) {
case 'event':
final data = call.arguments as String? ?? '';
final methodCall = CoreMethodCall.fromJson(
Map<String, Object?>.from(json.decode(data) as Map),
Map<String, Object?>.from(
_decodeUtf8Json(call.arguments as Uint8List) as Map,
),
);
for (final event in coreEventsFromData(methodCall.arguments)) {
_dispatch(
@@ -72,7 +75,7 @@ class Service {
}
Future<CoreMethodResponse?> invokeMethod(CoreMethodCall call) async {
final data = await methodChannel.invokeMethod<Uint8List>(
final data = await _coreChannel.invokeMethod<Uint8List>(
'invokeMethod',
json.encode(call),
);
@@ -80,7 +83,9 @@ class Service {
return null;
}
final dataJson = await _decodeResponse(data);
return CoreMethodResponse.fromJson(dataJson as Map<String, dynamic>);
return resolveSpilledResponse(
CoreMethodResponse.fromJson(dataJson as Map<String, dynamic>),
);
}
void _dispatch(String label, void Function(ServiceListener) deliver) {
+49 -10
View File
@@ -1,12 +1,17 @@
import 'dart:convert';
import 'dart:io';
import 'package:fl_clash/common/constant.dart';
import 'package:fl_clash/core/method.dart';
import 'package:fl_clash/core/spill.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/plugins/service.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart';
import '../helpers/app_paths.dart';
class _RecordingListener with ServiceListener {
final events = <CoreEvent>[];
@@ -53,18 +58,25 @@ void main() {
const channelName = '$packageName/service';
const channel = MethodChannel(channelName);
const coreChannel = MethodChannel('$packageName/core');
const codec = StandardMethodCodec();
late List<MethodCall> calls;
void mockChannel(Future<Object?>? Function(MethodCall call) handler) {
void mockChannel(
Future<Object?>? Function(MethodCall call) handler, {
MethodChannel target = channel,
}) {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
.setMockMethodCallHandler(target, (call) async {
calls.add(call);
return handler(call);
});
}
void mockCoreChannel(Future<Object?>? Function(MethodCall call) handler) =>
mockChannel(handler, target: coreChannel);
Future<void> emitFromPlatform(Object? arguments) async {
final payload = json.encode(
CoreMethodCall(method: CoreMethod.message, arguments: arguments).toJson(),
@@ -72,7 +84,7 @@ void main() {
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
channelName,
codec.encodeMethodCall(MethodCall('event', payload)),
codec.encodeMethodCall(MethodCall('event', utf8.encode(payload))),
null,
);
}
@@ -82,8 +94,10 @@ void main() {
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
for (final target in [channel, coreChannel]) {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(target, null);
}
});
test('is a singleton so the platform handler is installed once', () {
@@ -259,7 +273,7 @@ void main() {
group('invokeMethod', () {
test('encodes the call and decodes the response envelope', () async {
mockChannel(
mockCoreChannel(
(_) async => utf8.encode(
json.encode(
const CoreMethodResponse(id: '7', result: {'up': 1}).toJson(),
@@ -292,7 +306,7 @@ void main() {
test('decodes a large UTF-8 envelope off the calling isolate', () async {
final names = List.generate(5000, (index) => '节点-$index');
mockChannel(
mockCoreChannel(
(_) async => utf8.encode(
json.encode(CoreMethodResponse(id: '3', result: names).toJson()),
),
@@ -305,8 +319,33 @@ void main() {
expect(response!.result, names);
});
test('reads an envelope the Core spilled to a file', () async {
final home = Directory.systemTemp.createTempSync('service_spill');
addTearDown(() => home.deleteSync(recursive: true));
await useAppPaths(home);
final file = File(join(home.path, coreSpillDirectoryName, '1.json'))
..createSync(recursive: true)
..writeAsStringSync(
json.encode(
const CoreMethodResponse(id: '8', result: 'big').toJson(),
),
);
mockCoreChannel(
(_) async => utf8.encode(
json.encode(CoreMethodResponse(id: '8', file: file.path).toJson()),
),
);
final response = await Service().invokeMethod(
const CoreMethodCall(id: '8', method: CoreMethod.getConfig),
);
expect(response!.result, 'big');
expect(file.existsSync(), isFalse);
});
test('returns null when the platform sends no envelope', () async {
mockChannel((_) async => null);
mockCoreChannel((_) async => null);
expect(
await Service().invokeMethod(
@@ -317,7 +356,7 @@ void main() {
});
test('surfaces a Core error inside the envelope', () async {
mockChannel(
mockCoreChannel(
(_) async => utf8.encode(
json.encode({
'id': '1',
@@ -452,7 +491,7 @@ void main() {
.defaultBinaryMessenger
.handlePlatformMessage(
channelName,
codec.encodeMethodCall(const MethodCall('event', '')),
codec.encodeMethodCall(MethodCall('event', Uint8List(0))),
null,
);