连接优化与复用:让每一次握手都物超所值|Android网络优化系列(3)

Android网络优化系列 · 第3/5篇

从DNS到连接池,打造极速网络体验

第1篇:Android网络全链路拆解:一次HTTP请求背后的性能陷阱

第2篇:DNS优化实战:从运营商DNS到HttpDNS的进化之路

第3篇:连接优化与复用:让每一次握手都物超所值(本篇)

⏳ 第4篇:数据压缩与缓存策略:把带宽用到极致

⏳ 第5篇:网络监控与容灾:让网络问题无处遁形

从一次"诡异的300ms"说起

上篇我们拿下了DNS------解析耗时P99从2100ms砍到180ms,劫持率从0.8%降到0.01%。你以为胜利了,直到有人扔过来一个性能trace:

"为什么同一个接口,第一次请求和第二次请求差了300ms?DNS已经缓存命中了啊。"

打开trace一看:DNS解析确实是0ms(命中缓存),但多了一段300ms的"connectStart → connectEnd"。这300ms是什么?是TCP三次握手(1 RTT)+ TLS 1.2握手(2 RTT)。RTT约100ms时,三次握手就是300ms的"入场费"------还没发一个字节的业务数据呢。

更绝的是,这不是个例。我们一查线上数据:连接复用率只有62%------每10个请求里有接近4个需要重新建连。乘以300ms的连接成本,这是一笔巨大的性能税。

这就是今天的主题:怎么把"新建连接"的次数降到最低,把"每次连接"的成本压到最小

一次握手到底花多少钱

先把账算清楚。假设客户端到服务端的RTT(往返时延)是100ms(4G网络的典型值):

建连成本拆解

• TCP三次握手:1 RTT = 100ms

• TLS 1.2握手:2 RTT = 200ms(ClientHello → ServerHello+证书 → 密钥交换 → Finished)

• 合计 = 300ms,一个字节的业务数据都还没发

弱网放大效应(RTT=300ms时)

• TCP + TLS 1.2 = 3 × 300ms = 900ms

• 首屏如果要请求3个不同域名 → 串行建连 = 2.7秒

900ms什么概念?用户点一个按钮,等了将近一秒才开始传数据,加上服务端处理时间和数据传输时间,首屏轻松超过2秒。Google的研究表明,页面加载每慢100ms,转化率下降1.11%。你这一个连接建立就丢了10%的转化。

这就是为什么连接优化的ROI极高------你不是在优化一个请求,而是在优化所有请求共享的底座

OkHttp连接池:最好的握手是不握手

最快的连接建立方式,是根本不建立------复用已有的连接。OkHttp的ConnectionPool就是干这件事的:把已经完成TCP+TLS握手的连接缓存起来,下一个相同目标的请求直接拿来用。

默认配置的隐患

ini 复制代码
// OkHttp 默认连接池配置
val defaultPool = ConnectionPool(
    maxIdleConnections = 5,      // 最多保留5个空闲连接
    keepAliveDuration = 5,       // 空闲连接存活5分钟
    timeUnit = TimeUnit.MINUTES
)

对Demo App来说够用。但想想生产环境你的App同时在和多少域名通信:

api.yourapp.com --- 主接口

cdn.yourapp.com --- 静态资源/配置

img.yourapp.com --- 图片

tracker.yourapp.com --- 埋点

push.yourapp.com --- 长连接推送

• 若干第三方SDK域名...

5个空闲连接装不下这些域名。低频域名的连接不断被挤出池子,下次用到又要重新握手。

生产级连接池调参

ini 复制代码
/**
 * 生产级连接池配置
 * 原则:覆盖所有高频域名的常驻连接,又不过度占用资源
 */
val productionPool = ConnectionPool(
    maxIdleConnections = 15,     // 覆盖全部高频域名,每个域名至少1-2个空闲连接
    keepAliveDuration = 10,      // 10分钟------移动端用户操作间隔可能较长
    timeUnit = TimeUnit.MINUTES
)

val client = OkHttpClient.Builder()
    .connectionPool(productionPool)
    .build()

但有一个反直觉的点:maxIdleConnections不是越大越好。每个空闲连接占约几十KB内存和一个文件描述符(fd)。Android默认的fd上限是1024,连接池囤太多空闲连接会挤压业务的fd余量。我们压测下来**,15-20是一个比较好的平衡点**。

keepAliveDuration的陷阱

keepAliveDuration要配合服务端的Keep-Alive超时来设。客户端的必须小于服务端的。否则出现这个经典bug:

客户端认为连接还活着(在keepAliveDuration内),但服务端已经关了连接(超过了Nginx的keepalive_timeout 75s)。客户端在这个"半死"连接上发请求 → 收到RST → OkHttp检测到后重试 → 白白多花一个RTT。

ini 复制代码
// Nginx默认 keepalive_timeout 75s
// 客户端设60s,留15s安全余量
val safePool = ConnectionPool(
    maxIdleConnections = 15,
    keepAliveDuration = 60,      // 比服务端的75s少15s
    timeUnit = TimeUnit.SECONDS
)

//  如果你的keepAliveDuration设成10分钟,但服务端只有75秒
// 那60秒后到10分钟之间的所有复用都会遇到RST
// 这是线上最常见的"偶发请求多花一个RTT"的原因

用数据说话:连接复用率监控

调参不能靠猜,要靠数据。OkHttp的EventListener接口让你精确看到每一个连接的生命周期:

kotlin 复制代码
class ConnectionMetricsListener : EventListener() {

    private var connectStartMs = 0L

    override fun connectStart(
        call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy
    ) {
        connectStartMs = System.currentTimeMillis()
    }

    override fun connectionAcquired(
        call: Call, connection: Connection
    ) {
        // 如果connectionAcquired之前没有触发connectStart → 复用
        val isReused = connectStartMs == 0L
        Metrics.increment(
            if (isReused) "conn_reused" else "conn_new"
        )
    }

    override fun connectEnd(
        call: Call, inetSocketAddress: InetSocketAddress,
        proxy: Proxy, protocol: Protocol?
    ) {
        val handshakeMs = System.currentTimeMillis() - connectStartMs
        Metrics.histogram("tcp_tls_handshake_ms", handshakeMs)
        Metrics.tag("protocol", protocol?.toString() ?: "unknown")
    }
}

val client = OkHttpClient.Builder()
    .connectionPool(productionPool)
    .eventListenerFactory { ConnectionMetricsListener() }
    .build()

盯两个核心指标:连接复用率 (目标>85%)和新建连接的握手耗时P95。如果复用率掉到70%以下,说明连接池配置有问题------要么maxIdleConnections不够,要么keepAliveDuration太短,要么域名太分散。

HTTP/2多路复用:一条连接,并发百请求

连接池解决了"别重复建连"的问题,但HTTP/1.1还有一个根本缺陷:一个连接同一时刻只能处理一个请求(Head-of-Line Blocking)。想并发10个请求?必须开10个连接,每个都要付300ms的握手费。

HTTP/2的多路复用彻底打破了这个限制------在一条TCP连接上,把请求/响应拆成帧(Frame),用Stream ID标识归属,多个请求的帧交错传输,接收端按Stream ID重新组装。

实际收益有三个层面:

1. 多路复用(Multiplexing)------一条连接并发几百个请求,不再需要为并发开多连接

2. 头部压缩(HPACK) ------一个典型API请求头500字节~2KB,100个请求就是100-200KB的重复数据。HPACK通过静态表(61个预定义常见头)+ 动态表(连接生命周期内的头部缓存),把连续请求的头部压缩率做到85-95%。第一个请求800字节头,后续只传30-50字节。

3. 流优先级(Stream Priority)------多请求共用连接时,你可以告诉服务端"首屏API优先级最高,图片次之,埋点最低"。服务端据此调度响应顺序,关键资源先到。

OkHttp的HTTP/2:零配置但不是零优化

OkHttp从3.x起默认支持HTTP/2------只要服务端支持,TLS握手时通过ALPN协商自动升级。你不需要改一行代码。但"默认支持"和"用好"是两码事:

scss 复制代码
/**
 * HTTP/2 最佳实践配置
 */
val http2Client = OkHttpClient.Builder()
    .connectionPool(productionPool)
    // 确保TLS版本支持ALPN协商
    .connectionSpecs(listOf(
        ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
            .tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2)
            .build()
    ))
    // PING心跳:HTTP/2 PING帧检测连接健康度
    .pingInterval(30, TimeUnit.SECONDS)
    .build()

pingInterval这个参数极易被忽略但极为重要。HTTP/2连接是长活的,移动端网络频繁切换(WiFi↔4G、进出电梯),连接可能已经断了但双方都不知道。PING帧是心跳探测,30秒一次,及时发现死连接并丢弃。没有这个配置,请求可能发到一个已经断开的连接上,等超时后才发现------白白浪费十几秒。

域名收敛:HTTP/2时代的必修课

HTTP/1.1时代有个"常识"------域名发散:把静态资源分散到img1.cdn.com, img2.cdn.com...绕开每域名6连接的限制。

在HTTP/2时代,这变成了反模式。一条连接就能并发几百个请求,你分散域名反而导致:每个域名单独建连+TLS握手、HPACK动态表无法跨连接共享、连接池里一堆同质低效连接。

正确做法是域名收敛 。如果业务原因必须多域名,至少确保它们解析到同一IP、共用同一张通配符证书(或SAN证书覆盖所有子域名)。OkHttp会自动进行连接合并(Connection Coalescing)

arduino 复制代码
// OkHttp 连接合并条件(全部满足才触发):
// 1. 两个域名解析到同一IP
// 2. TLS证书匹配(SAN包含两个域名,或通配符如 *.yourapp.com)
// 3. 目标端口相同
//
// 实际效果:api.yourapp.com 和 cdn.yourapp.com 虽然域名不同
// 但共享同一条HTTP/2连接 → 握手成本只付一次
//
// 你需要确保的基础设施条件:
// - DNS解析到同一组IP(通过CDN配置或自有LB)
// - 使用通配符证书:*.yourapp.com

我们把7个子域名收敛成3个+通配符证书后,活跃连接数从平均12条降到4条,连接复用率直接从62%跳到了78%(还没做其他优化)。

TLS 1.3:省一个RTT的质变

连接复用解决了"能不能不握手"的问题。TLS 1.3解决的是"不得不握手时能不能更快"。

TLS 1.2握手要2个RTT。TLS 1.3把这砍到了1个RTT ,恢复会话(Session Resumption)可以做到0-RTT。怎么做到的?

首次连接=1 RTT:TLS 1.3合并了密钥协商和认证步骤。ClientHello里直接附带密钥参数(Key Share),服务端在ServerHello里一次性返回证书和密钥参数。一个来回搞定。

会话恢复=0-RTT:之前连过这个服务器?客户端缓存PSK(Pre-Shared Key),下次连接时ClientHello里直接带PSK+加密后的应用数据------握手还没完成,数据已经发出去了。

** 0-RTT的安全权衡**

0-RTT数据不具备前向保密性,且存在重放攻击风险。只适合幂等请求(GET),绝不能用于POST/PUT等写操作。服务端也需要做重放保护(如单次ticket机制)。

低版本Android启用TLS 1.3

Android 10+原生支持TLS 1.3。但你的用户不全是Android 10+。覆盖低版本设备的方案:

kotlin 复制代码
/**
 * 在Application.onCreate()中启用TLS 1.3
 * 覆盖Android 7+ 设备
 */
fun enableTls13(context: Context) {
    try {
        // 方案1:Google Play Services(有GMS的设备)
        ProviderInstaller.installIfNeeded(context)
    } catch (e: Exception) {
        try {
            // 方案2:Conscrypt(无GMS的设备,如华为)
            Security.insertProviderAt(Conscrypt.newProvider(), 1)
        } catch (e2: Exception) {
            Log.w("TLS", "TLS 1.3 not available on this device", e2)
        }
    }
}

升级后的收益一目了然:

连接建立总耗时(RTT=100ms)

• HTTP/1.1 + TLS 1.2 = 3 RTT = 300ms

• HTTP/2 + TLS 1.3(首次)= 2 RTT = 200ms

• HTTP/2 + TLS 1.3(0-RTT恢复)= 1 RTT = 100ms

• 连接池复用 = 0ms

QUIC/HTTP3:干掉TCP的最后一个队头阻塞

HTTP/2解决了HTTP层的队头阻塞,但TCP层的还在。什么意思?

TCP是有序可靠的字节流。一个TCP包丢了,即使后续包已经到达,TCP也不让应用层读------必须等丢失的包重传成功。HTTP/2下多个Stream共用一条TCP连接,一个Stream的丢包阻塞所有Stream

移动端丢包率远高于有线网络(4G正常1-3%,弱信号下10%+)。一个讽刺的事实:高丢包环境下,HTTP/2可能比HTTP/1.1更慢------因为HTTP/1.1开了6个连接,一个连接丢包不影响其他5个。

QUIC从传输层根治了这个问题------基于UDP,每个Stream是独立传输单元,丢包只影响自身。加上0-RTT建连和连接迁移(WiFi切4G无缝续上),QUIC是移动端的最终形态。

Android端QUIC接入

scss 复制代码
// 方案:Cronet(Chromium网络栈独立封装,QUIC原生支持)
// Google Play Services 内置,不增加APK体积

implementation("com.google.android.gms:play-services-cronet:18.1.0")

val engine = CronetEngine.Builder(context)
    .enableQuic(true)
    .enableHttp2(true)
    .addQuicHint("api.yourapp.com", 443, 443)  // 跳过Alt-Svc发现阶段
    .setStoragePath(cacheDir.absolutePath)
    .enableHttpCache(
        CronetEngine.Builder.HTTP_CACHE_DISK,
        10L * 1024 * 1024  // 10MB
    )
    .build()

// 桥接OkHttp API(保留拦截器生态)
implementation("com.google.net.cronet:cronet-okhttp:0.1.0")

val client = OkHttpClient.Builder()
    .addInterceptor(CronetInterceptor.newBuilder(engine).build())
    .build()

QUIC落地的几个坑

服务端先行:Nginx 1.25+ 支持HTTP/3,Caddy原生支持,但你得确保运维同学配好了

UDP封锁:部分企业网络和运营商限制UDP流量。QUIC连接建不起来时,必须有TCP降级策略------Cronet会自动处理这个

Alt-Svc发现延迟:正常流程是第一次走TCP,服务端通过Alt-Svc头告知"我支持QUIC",第二次才切。addQuicHint可以跳过这一步

调试困难:QUIC流量是加密的UDP包,tcpdump/Wireshark抓包比TCP难很多。qlog是QUIC的官方日志格式,建议开启

我们的QUIC灰度数据(10%流量,持续2周):

QUIC vs HTTP/2(相同服务端,相同用户群)

• 建连耗时 P50:QUIC 80ms vs HTTP/2 190ms(-58%)

• 弱网(丢包>5%)请求成功率:QUIC 96% vs HTTP/2 87%

• WiFi→4G切换期间请求失败率:QUIC 0.3% vs HTTP/2 4.2%

• UDP被封锁回退TCP的比例:约3%

连接迁移的数据最亮眼------网络切换导致的失败率从4.2%降到0.3%。这对通勤场景(WiFi→地铁4G→无信号→恢复信号)的体验提升是质的。

连接预建立:利用"认知间隙"

上一篇讲了DNS预解析------在用户操作之前把域名解析好。这一步更进一步:不仅解析好DNS,连TCP+TLS握手也提前做完

原理很简单:用户的眼睛和大脑在处理页面跳转动画的300-500ms内,网络层在后台把下一个页面需要的连接建好放进池里。用户真正触发请求时,连接已经ready,请求直接发。

kotlin 复制代码
/**
 * 连接预热管理器
 * 在关键节点提前建连,让后续请求零等待
 */
object ConnectionWarmUp {

    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

    // 场景→需要预热的URL
    private val configs = mapOf(
        "app_launch" to listOf(
            "https://api.yourapp.com/health",
            "https://cdn.yourapp.com/ping"
        ),
        "enter_product_list" to listOf(
            "https://api.yourapp.com/product/prefetch"
        ),
        "enter_payment" to listOf(
            "https://pay.yourapp.com/health"
        )
    )

    /**
     * 触发预热------HEAD请求建连后立即关闭,连接留在池中
     */
    fun warmUp(scene: String) {
        val urls = configs[scene] ?: return
        scope.launch {
            urls.forEach { url ->
                launch {
                    runCatching {
                        val req = Request.Builder().url(url).head().build()
                        client.newCall(req).execute().close()
                    }
                }
            }
        }
    }
}

// 使用时机:onCreate中触发(此时转场动画正在播放)
class ProductDetailActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ConnectionWarmUp.warmUp("enter_product_list")
        // 转场动画约300ms,此时后台正在建连
        // onResume中发真正的请求时,连接已经在池里了
    }
}

弱网场景:连接降级与智能重试

前面讲的都是"好网络下怎么更快"。移动端的现实是,永远有用户在电梯里、地铁上、荒郊野外。弱网策略不是锦上添花,是底线

实时网络质量探测

kotlin 复制代码
/**
 * 基于滑动窗口的网络质量分级
 * 从EventListener收集RTT和失败数据,实时判断网络状况
 */
enum class NetworkGrade {
    EXCELLENT,  // RTT < 100ms, 丢包 < 1%
    GOOD,       // RTT < 300ms, 丢包 < 3%
    MODERATE,   // RTT < 800ms, 丢包 < 5%
    POOR,       // RTT < 2000ms, 丢包 < 10%
    TERRIBLE    // 更差
}

object NetworkProbe {

    private val window = ArrayDeque<Long>(20)  // 最近20次RTT
    private var fails = 0
    private var total = 0

    fun onRtt(ms: Long) {
        if (window.size >= 20) window.removeFirst()
        window.addLast(ms); total++
    }

    fun onFail() { fails++; total++ }

    fun grade(): NetworkGrade {
        if (window.isEmpty()) return NetworkGrade.GOOD
        val avg = window.average().toLong()
        val loss = if (total > 0) fails.toFloat() / total else 0f
        return when {
            avg  NetworkGrade.EXCELLENT
            avg  NetworkGrade.GOOD
            avg  NetworkGrade.MODERATE
            avg  NetworkGrade.POOR
            else -> NetworkGrade.TERRIBLE
        }
    }
}

自适应策略+指数退避重试

kotlin 复制代码
/**
 * 自适应拦截器:根据网络质量调整超时和请求参数
 */
class AdaptiveInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        var request = chain.request()
        when (NetworkProbe.grade()) {
            NetworkGrade.EXCELLENT, NetworkGrade.GOOD -> {
                return chain
                    .withConnectTimeout(15, TimeUnit.SECONDS)
                    .withReadTimeout(30, TimeUnit.SECONDS)
                    .proceed(request)
            }
            NetworkGrade.MODERATE -> {
                // 请求中清图片,加长超时
                request = request.newBuilder()
                    .header("X-Image-Quality", "medium").build()
                return chain
                    .withConnectTimeout(25, TimeUnit.SECONDS)
                    .withReadTimeout(45, TimeUnit.SECONDS)
                    .proceed(request)
            }
            NetworkGrade.POOR, NetworkGrade.TERRIBLE -> {
                // 缩略图 + 标记降级 + 极端超时
                request = request.newBuilder()
                    .header("X-Image-Quality", "low")
                    .header("X-Degradation", "true").build()
                return chain
                    .withConnectTimeout(30, TimeUnit.SECONDS)
                    .withReadTimeout(60, TimeUnit.SECONDS)
                    .proceed(request)
            }
        }
    }
}

/**
 * 指数退避重试:失败后等待时间翻倍 + 随机抖动
 * 避免服务端恢复时被重试洪流冲垮
 */
class RetryInterceptor(
    private val maxRetries: Int = 3,
    private val baseMs: Long = 500,
    private val maxMs: Long = 8000
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        var lastErr: IOException? = null

        repeat(maxRetries + 1) { attempt ->
            try {
                val resp = chain.proceed(request)
                if (resp.code in listOf(502, 503) && attempt < maxRetries) {
                    resp.close()
                    Thread.sleep(delay(attempt))
                    return@repeat
                }
                return resp
            } catch (e: IOException) {
                lastErr = e
                if (attempt < maxRetries) Thread.sleep(delay(attempt))
            }
        }
        throw lastErr ?: IOException("Retry exhausted")
    }

    private fun delay(attempt: Int): Long {
        val exp = minOf(baseMs * (1L shl attempt), maxMs)
        return exp + (exp * Random.nextFloat() * 0.5f).toLong()
        // 500→1000→2000→4000... + 0~50%随机抖动
    }
}

为什么加随机抖动(Jitter)?想象服务端刚从故障恢复,百万客户端同时重试。如果都是精确的1s、2s、4s间隔,流量会形成周期性脉冲,一波波冲击服务端。加了随机抖动,重试流量被打散均匀,服务端压力平滑得多。这是分布式系统的基本卫生。

综合实战效果

我们在同一个日活500万的App上,叠加了全部连接优化(上一篇DNS优化已上线):

优化组合拳

  1. 连接池调优(5→15空闲连接,keepAlive对齐服务端)

  2. 域名收敛(7→3域名 + 通配符证书启用连接合并)

  3. TLS 1.3全量升级(Conscrypt覆盖低版本设备)

  4. 连接预建立(App启动 + 页面跳转两个时机)

  5. HTTP/2 PING心跳30s + 死连接清理

  6. 弱网自适应 + 指数退避重试

AB测试结果(叠加DNS优化后的增量)

• 连接复用率:62% → 91%(+29pp)

• 新建连接握手耗时P50:220ms → 130ms(TLS 1.3贡献)

• 首屏接口总耗时P50:280ms → 195ms(-85ms)

• 首屏接口总耗时P99:900ms → 520ms(-380ms)

• 弱网请求成功率:82% → 94%(+12pp)

最关键的数字是连接复用率从62%到91%------每10个请求有9个免去了握手延迟,直接发数据。配合DNS缓存命中(P50=0ms),大部分请求从"用户点击"到"数据开始传输"的延迟接近于零。

弱网成功率82%→94%也很有价值。那12%原本会白屏的用户,现在至少能看到降级内容。这是用户留存率的隐形守护者。

小结与选型决策

这篇的核心思路很简单------尽量不建连(复用),不得不建连时尽量快(TLS 1.3/QUIC),连上后保持住别丢(心跳+降级)。具体结论:

• 连接池调参+监控复用率,目标>85%

• HTTP/2多路复用+域名收敛+连接合并,大幅减少活跃连接数

• TLS 1.3全量升级,首次握手省100ms,0-RTT恢复省200ms

• QUIC根治TCP队头阻塞,弱网环境质变(但需服务端配合)

• 连接预建立利用认知间隙,用户体感零延迟

• 弱网策略是底线不是锦上添花:探测→降级→退避重试

落地优先级建议:

第一步(零风险/立竿见影):连接池调参 + TLS 1.3启用 + PING心跳

第二步(中等投入):域名收敛 + 连接预建立 + 复用率监控

第三步(提升长尾):弱网探测 + 自适应降级 + 指数退避重试

第四步(长线投资):QUIC灰度 → 全量

到这一步,DNS和连接都已经优化到位------"找到服务器"和"连上服务器"的成本都压到了极致。接下来的瓶颈在数据本身 。下一篇我们聊数据压缩与缓存策略:怎么让该传的数据更小,怎么让不该传的数据根本不传。同样一个接口,传100KB和传10KB的差距,在3G网络下可以是好几秒。

Android网络优化系列 · 第3/5篇

从DNS到连接池,打造极速网络体验

第1篇:Android网络全链路拆解:一次HTTP请求背后的性能陷阱

第2篇:DNS优化实战:从运营商DNS到HttpDNS的进化之路

第3篇:连接优化与复用:让每一次握手都物超所值(本篇)

⏳ 第4篇:数据压缩与缓存策略:把带宽用到极致

⏳ 第5篇:网络监控与容灾:让网络问题无处遁形

--- 系列持续更新中,关注不迷路 ---

相关推荐
zhangphil2 小时前
Android Bitmap.Config.HARDWARE属性产生的来源和控制权
android
YF02112 小时前
深度解构Android OkDownload断点续传
android·数据库·okhttp
Co_Hui2 小时前
Android: Service基本使用
android
恋猫de小郭3 小时前
Android Studio 放着没怎么用,怎么也会越来越卡?
android·前端·flutter
Kapaseker3 小时前
Compose 动画 — 显隐的艺术
android·kotlin
黄林晴3 小时前
Android官方发布 AppFunctions,让系统AI直接调用你的APP
android·agent
2501_915909064 小时前
完整指南:如何将iOS应用上架到App Store
android·ios·小程序·https·uni-app·iphone·webview
赏金术士6 小时前
Retrofit + Kotlin 协程(Android 实战教程)
android·kotlin·retrofit
大炮筒13 小时前
COCOS2DX4.0CPPWIN移植安卓踩坑总结
android