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"