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:
@@ -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
-5
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user