diff --git a/apps/mobile/android/app/src/main/kotlin/com/photolive/photo_live/UploadPutExecutor.kt b/apps/mobile/android/app/src/main/kotlin/com/photolive/photo_live/UploadPutExecutor.kt index 12c16b5..9670cfa 100644 --- a/apps/mobile/android/app/src/main/kotlin/com/photolive/photo_live/UploadPutExecutor.kt +++ b/apps/mobile/android/app/src/main/kotlin/com/photolive/photo_live/UploadPutExecutor.kt @@ -38,11 +38,15 @@ sealed class PutOutcome { * - 请求头:严格携带 Dart 传入的 credential.headers(Content-Type 经 * RequestBody.contentType 表达,Content-Length 由 okhttp 按声明长度 * 发送,二者不重复注入); - * - 进度:写 body 驱动 [onProgress](已发字节/总字节,IO 线程回调); + * - 进度:写 body 驱动 [onProgress](已发字节/总字节,IO 线程回调; + * 256KB 步长节流,末块必报 sent==total); * - OkHttp 独立实例(4.12,与 Flutter 引擎无耦合;连接失败内置静默重试)。 */ object UploadPutExecutor { + /** 进度回调最小步长(字节);末块必报。 */ + private const val PROGRESS_STEP_BYTES = 256L * 1024 + private val defaultClient: OkHttpClient = OkHttpClient.Builder() .connectTimeout(Duration.ofSeconds(15)) .writeTimeout(Duration.ofMinutes(3)) @@ -75,6 +79,7 @@ object UploadPutExecutor { file.inputStream().use { input -> val buffer = ByteArray(64 * 1024) var sent = 0L + var lastReported = 0L while (true) { val read = input.read(buffer) if (read < 0) { @@ -82,7 +87,10 @@ object UploadPutExecutor { } sink.write(buffer, 0, read) sent += read - onProgress?.invoke(sent, total) + if (sent - lastReported >= PROGRESS_STEP_BYTES || sent == total) { + lastReported = sent + onProgress?.invoke(sent, total) + } } sink.flush() } diff --git a/apps/mobile/android/app/src/test/java/com/photolive/photo_live/UploadPutExecutorTest.kt b/apps/mobile/android/app/src/test/java/com/photolive/photo_live/UploadPutExecutorTest.kt index 6358bb5..feda9c9 100644 --- a/apps/mobile/android/app/src/test/java/com/photolive/photo_live/UploadPutExecutorTest.kt +++ b/apps/mobile/android/app/src/test/java/com/photolive/photo_live/UploadPutExecutorTest.kt @@ -14,7 +14,7 @@ import org.junit.Test * UploadPutExecutor 结局分类测试(P3 计划 Task 7;MockWebServer 驱动): * 200→Success(etag)、500→Retryable(server)、403→RenewRequired、 * 4xx→HttpFail、连接失败→Retryable(network)、缺文件→SourceMissing、 - * 请求头透传与进度回调。 + * 请求头透传与进度回调(单调性 + 256KB 步长节流/末块必报)。 */ class UploadPutExecutorTest { @@ -147,6 +147,35 @@ class UploadPutExecutorTest { samples.forEach { assertEquals(file.length(), it.second) } } + @Test + fun `progress callback throttles to 256KB steps and reports final chunk`() { + // 700KB 夹具:64KB 写循环下未节流会有 ~11 次回调;节流后仅在 + // 累计 ≥256KB 或末块(sent==total)时上报。 + server.enqueue(MockResponse().setResponseCode(200)) + val big = Files.createTempFile("upload-put-big", ".bin") + big.toFile().deleteOnExit() + val bigFile = big.toFile() + Files.write(big, ByteArray(700_000) { (it % 251).toByte() }) + val samples = mutableListOf>() + UploadPutExecutor.execute( + url = server.url("/staging/x.jpg").toString(), + method = "PUT", + headers = mapOf("Content-Type" to "image/jpeg"), + filePath = bigFile.absolutePath, + client = client, + ) { sent, total -> samples.add(sent to total) } + // 末块必报:sent == total。 + assertEquals(bigFile.length(), samples.last().first) + assertEquals(bigFile.length(), samples.last().second) + // 步长节流:相邻回调字节差 ≥256KB(末次为余量,可小于步长)。 + val step = 256L * 1024 + samples.zipWithNext().dropLast(1).forEach { (prev, next) -> + assertTrue(next.first - prev.first >= step) + } + // 节流生效:回调次数少于 64KB 写循环次数。 + assertTrue(samples.size < (bigFile.length() / (64 * 1024)).toInt()) + } + @Test fun `blank method defaults to PUT and uppercases`() { assertEquals("PUT", UploadPutExecutor.normalizeMethod("")) diff --git a/apps/mobile/lib/features/uploads/native_transport.dart b/apps/mobile/lib/features/uploads/native_transport.dart index 0fe4f91..9b484e1 100644 --- a/apps/mobile/lib/features/uploads/native_transport.dart +++ b/apps/mobile/lib/features/uploads/native_transport.dart @@ -139,9 +139,15 @@ class DartHttpTransport implements Transport { final file = File(filePath); final length = await file.length(); var sent = 0; + var lastReported = 0; + // 256KB 步长节流(与原生 UploadPutExecutor 一致);末块必报 sent==length。 + const progressStep = 256 * 1024; final progressStream = file.openRead().map((List chunk) { sent += chunk.length; - onProgress?.call(sent, length); + if (sent - lastReported >= progressStep || sent == length) { + lastReported = sent; + onProgress?.call(sent, length); + } return chunk; }); try {