perf(app): throttle upload progress callbacks to 256KB steps
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+10
-2
@@ -38,11 +38,15 @@ sealed class PutOutcome {
|
|||||||
* - 请求头:严格携带 Dart 传入的 credential.headers(Content-Type 经
|
* - 请求头:严格携带 Dart 传入的 credential.headers(Content-Type 经
|
||||||
* RequestBody.contentType 表达,Content-Length 由 okhttp 按声明长度
|
* RequestBody.contentType 表达,Content-Length 由 okhttp 按声明长度
|
||||||
* 发送,二者不重复注入);
|
* 发送,二者不重复注入);
|
||||||
* - 进度:写 body 驱动 [onProgress](已发字节/总字节,IO 线程回调);
|
* - 进度:写 body 驱动 [onProgress](已发字节/总字节,IO 线程回调;
|
||||||
|
* 256KB 步长节流,末块必报 sent==total);
|
||||||
* - OkHttp 独立实例(4.12,与 Flutter 引擎无耦合;连接失败内置静默重试)。
|
* - OkHttp 独立实例(4.12,与 Flutter 引擎无耦合;连接失败内置静默重试)。
|
||||||
*/
|
*/
|
||||||
object UploadPutExecutor {
|
object UploadPutExecutor {
|
||||||
|
|
||||||
|
/** 进度回调最小步长(字节);末块必报。 */
|
||||||
|
private const val PROGRESS_STEP_BYTES = 256L * 1024
|
||||||
|
|
||||||
private val defaultClient: OkHttpClient = OkHttpClient.Builder()
|
private val defaultClient: OkHttpClient = OkHttpClient.Builder()
|
||||||
.connectTimeout(Duration.ofSeconds(15))
|
.connectTimeout(Duration.ofSeconds(15))
|
||||||
.writeTimeout(Duration.ofMinutes(3))
|
.writeTimeout(Duration.ofMinutes(3))
|
||||||
@@ -75,6 +79,7 @@ object UploadPutExecutor {
|
|||||||
file.inputStream().use { input ->
|
file.inputStream().use { input ->
|
||||||
val buffer = ByteArray(64 * 1024)
|
val buffer = ByteArray(64 * 1024)
|
||||||
var sent = 0L
|
var sent = 0L
|
||||||
|
var lastReported = 0L
|
||||||
while (true) {
|
while (true) {
|
||||||
val read = input.read(buffer)
|
val read = input.read(buffer)
|
||||||
if (read < 0) {
|
if (read < 0) {
|
||||||
@@ -82,7 +87,10 @@ object UploadPutExecutor {
|
|||||||
}
|
}
|
||||||
sink.write(buffer, 0, read)
|
sink.write(buffer, 0, read)
|
||||||
sent += read
|
sent += read
|
||||||
onProgress?.invoke(sent, total)
|
if (sent - lastReported >= PROGRESS_STEP_BYTES || sent == total) {
|
||||||
|
lastReported = sent
|
||||||
|
onProgress?.invoke(sent, total)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sink.flush()
|
sink.flush()
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-1
@@ -14,7 +14,7 @@ import org.junit.Test
|
|||||||
* UploadPutExecutor 结局分类测试(P3 计划 Task 7;MockWebServer 驱动):
|
* UploadPutExecutor 结局分类测试(P3 计划 Task 7;MockWebServer 驱动):
|
||||||
* 200→Success(etag)、500→Retryable(server)、403→RenewRequired、
|
* 200→Success(etag)、500→Retryable(server)、403→RenewRequired、
|
||||||
* 4xx→HttpFail、连接失败→Retryable(network)、缺文件→SourceMissing、
|
* 4xx→HttpFail、连接失败→Retryable(network)、缺文件→SourceMissing、
|
||||||
* 请求头透传与进度回调。
|
* 请求头透传与进度回调(单调性 + 256KB 步长节流/末块必报)。
|
||||||
*/
|
*/
|
||||||
class UploadPutExecutorTest {
|
class UploadPutExecutorTest {
|
||||||
|
|
||||||
@@ -147,6 +147,35 @@ class UploadPutExecutorTest {
|
|||||||
samples.forEach { assertEquals(file.length(), it.second) }
|
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<Pair<Long, Long>>()
|
||||||
|
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
|
@Test
|
||||||
fun `blank method defaults to PUT and uppercases`() {
|
fun `blank method defaults to PUT and uppercases`() {
|
||||||
assertEquals("PUT", UploadPutExecutor.normalizeMethod(""))
|
assertEquals("PUT", UploadPutExecutor.normalizeMethod(""))
|
||||||
|
|||||||
@@ -139,9 +139,15 @@ class DartHttpTransport implements Transport {
|
|||||||
final file = File(filePath);
|
final file = File(filePath);
|
||||||
final length = await file.length();
|
final length = await file.length();
|
||||||
var sent = 0;
|
var sent = 0;
|
||||||
|
var lastReported = 0;
|
||||||
|
// 256KB 步长节流(与原生 UploadPutExecutor 一致);末块必报 sent==length。
|
||||||
|
const progressStep = 256 * 1024;
|
||||||
final progressStream = file.openRead().map((List<int> chunk) {
|
final progressStream = file.openRead().map((List<int> chunk) {
|
||||||
sent += chunk.length;
|
sent += chunk.length;
|
||||||
onProgress?.call(sent, length);
|
if (sent - lastReported >= progressStep || sent == length) {
|
||||||
|
lastReported = sent;
|
||||||
|
onProgress?.call(sent, length);
|
||||||
|
}
|
||||||
return chunk;
|
return chunk;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user