2025年4月20日·阅读约1分钟

Kotlin 在慢速连接下的网络:超时与安全重试

实用的 Kotlin 网络指南:为慢速连接设置超时、谨慎缓存、安全重试,并在不稳定移动网络上保护关键操作不被重复执行。

Kotlin 在慢速连接下的网络:超时与安全重试

在慢速和不稳定连接下会出什么问题

在移动端,“慢”通常不等于“没有网络”。更常见的是连接只在短时间内可用。一个请求可能需要 8 到 20 秒,中途卡住,然后再完成。或者某一刻成功,下一刻失败,因为手机从 Wi‑Fi 切换到 LTE、进入低信号区域,或系统把应用置于后台。

“波动(flaky)”更糟。数据包丢失、DNS 查找超时、TLS 握手失败、连接随机重置。即便代码写得“没问题”,也可能在真实环境中失败,因为网络在你运行时不断变化。

这正是默认设置容易出问题的地方。许多应用依赖库的超时、重试和缓存默认值,而没有决定对真实用户来说什么才是“足够好”。默认值通常针对稳定的 Wi‑Fi 和响应迅速的 API 调优,而不是通勤列车、电梯或繁忙的咖啡馆环境。

用户不会说“socket 超时”或“HTTP 503”。他们注意到的是症状:无尽的加载、长时间后出现的错误(然后下一次又成功了)、重复操作(两次预订、两笔订单、双重扣款)、丢失的更新,以及 UI 显示“失败”但服务器实际上已经成功的混乱状态。

慢速网络会把小的设计缺陷放大成金钱和信任问题。如果应用不能清晰区分“仍在发送”“失败”与“完成”,用户就会再次点击。如果客户端盲目重试,就可能产生重复请求。如果服务器不支持幂等性,抖动的连接就可能导致多个“成功”的写入。

“关键操作”指任何必须最多执行一次且必须正确的事情:付款、提交结账、预订时段、转账积分、修改密码、保存收货地址、提交索赔或发送审批等。

一个现实的例子:有人在弱 LTE 上提交结账。应用发送请求,但在响应到达前连接断开。用户看到错误,点了“支付”再次尝试,这时两个请求到达服务器。如果没有明确规则,客户端无法判断应该重试、等待还是停止。用户也无法判断是否应该再试一次。

在调整代码前先定规则

当连接慢或不稳定时,大多数错误来自于规则不清晰,而不是 HTTP 客户端。在你动超时、缓存或重试之前,先把“正确”对你的应用来说是什么意思写下来。

先从那些绝对不能执行两次的操作开始。这些通常是与金钱或账户相关的操作:下单、扣款、提交付款、改密码、删除账户。如果用户可能双击或应用重试时服务器还无法保证幂等,就把这些端点当作“禁止自动重试”直到你能保证安全为止。

接着,决定每个屏幕在网络糟糕时允许做什么。有些屏幕可以离线也有用(上次的个人资料、历史订单);有些屏幕应切为只读或显示明确的“重试”状态(库存数量、实时价格)。把这些期待混在一起会导致混乱的 UI 和危险的缓存行为。

根据用户的预期为每个操作设定可接受等待时间,而不是按代码里“看着舒服”的数字来定。登录能忍受较短的等待,文件上传需要更长,结账既要感觉快速又要安全。30 秒的超时在纸面上可能“可靠”,但用户体验上仍然会感觉很糟糕。

最后,决定在设备上要存什么以及保存多久。缓存能带来帮助,但过期的数据会导致错误决策(旧价格、过期资格)。

把规则写在大家都能找到的地方(README 就行)。保持简洁:

  • 哪些端点是“绝对不能重复”的,需要幂等处理?
  • 哪些屏幕必须离线可用,哪些在离线时变为只读?
  • 每个操作的最大等待时间是多少(登录、刷新、上传、结账)?
  • 哪些可以缓存到设备上,过期时间是多少?
  • 失败后是显示错误、排队稍后重试,还是要求用户手动重试?

规则一旦清晰,你的超时值、缓存头、重试策略和 UI 状态就更容易实现与测试。

符合真实用户预期的超时设置

慢速网络会以不同方式失败。好的超时设置不是随便“选个数”,而是要匹配用户正在做的事情,并能在失败时足够快地让应用恢复。

三个超时的通俗解释:

  • 连接超时(connect timeout):建立到服务器连接的最长等待时间(包括 DNS、TCP、TLS)。如果这个失败,请求根本没真正开始。
  • 写入超时(write timeout):发送请求体时的等待时间(上传、大 JSON、上行慢)。
  • 读取超时(read timeout):发送完请求后等待服务器返回数据的时间,这在移动网络不稳定时尤其明显。

超时应反映屏幕与风险程度。列表可以更慢而不会有大问题,关键操作要么完成要么明确失败,让用户能决定下一步。

一个实际的起点(根据测量再调整):

  • 列表加载(低风险):connect 5–10s,read 20–30s,write 10–15s。
  • 即时搜索(search-as-you-type):connect 3–5s,read 5–10s,write 5–10s。
  • 关键操作(高风险,如“支付”或“提交订单”):connect 5–10s,read 30–60s,write 15–30s。

一致性比完美更重要。如果用户点了“提交”看到两个分钟的转圈,就会再点一次。

通过在 UI 上加一个明确上限来避免“无限加载”。立即显示进度,允许取消,在(例如)20–30 秒后显示“仍在尝试…”,并给出重试或检查网络的选项。即便网络库仍在等待,这也能让体验诚实些。

当发生超时,记录足够的信息以便后续分析,但不要记录敏感信息。可用字段包括 URL 路径(不要包含完整 query)、HTTP 方法、状态(若有)、时长细分(connect vs write vs read)、网络类型(Wi‑Fi/移动/飞行模式)、大致请求/响应大小,以及请求 ID 以便将客户端日志与服务端日志对应起来。

简单且一致的 Kotlin 网络配置

在连接慢时,客户端配置上的小不一致会变成大问题。一个清晰的基线能让调试更快,并为每个请求提供相同的规则。

单一客户端,统一策略

从一个地方构建 HTTP 客户端(通常是一个由 Retrofit 使用的 OkHttpClient)开始。把基础配置放在这里,让每个请求都有相同的行为:默认头(应用版本、本地化、鉴权令牌)和明确的 User‑Agent、一次性设置的超时(而不是散落在各处)、可用于调试的日志以及统一的重试策略决定(即便是“不要自动重试”)。

下面是一个把配置集中在单个文件的小示例(代码块保留原样):

val okHttp = OkHttpClient.Builder()
  .connectTimeout(10, TimeUnit.SECONDS)
  .readTimeout(20, TimeUnit.SECONDS)
  .writeTimeout(20, TimeUnit.SECONDS)
  .callTimeout(30, TimeUnit.SECONDS)
  .addInterceptor { chain ->
    val request = chain.request().newBuilder()
      .header("User-Agent", "MyApp/${BuildConfig.VERSION_NAME}")
      .header("Accept", "application/json")
      .build()
    chain.proceed(request)
  }
  .build()

val retrofit = Retrofit.Builder()
  .baseUrl(BASE_URL)
  .client(okHttp)
  .addConverterFactory(MoshiConverterFactory.create())
  .build()

将错误映射为统一的用户友好信息

网络错误不只是“抛出异常”。如果每个屏幕各自处理一套信息,用户会看到杂乱无章的提示。

创建一个映射器,把失败归类为几个用户友好的结果:无连接/飞行模式、超时、服务器错误(5xx)、校验或鉴权错误(4xx),以及未知的兜底错误。

这样可以保持 UI 文案的一致(“无连接” vs “请重试”),不把技术细节泄露给用户。

当屏幕关闭时为请求打标签并取消

在不稳定网络下,请求可能晚到并更新已关闭的屏幕。把取消作为标准规则:屏幕关闭时,相关工作应停止。

在使用 Retrofit 与 Kotlin 协程时,取消协程作用域(例如在 ViewModel 中)会取消底层的 HTTP 调用。对非协程调用,保存 Call 引用并调用 cancel()。也可以给请求打 tag,在退出某个功能时按组取消。

后台任务不要依赖 UI

任何必须完成的重要操作(发送报告、同步队列、完成提交)应交给专门的调度器。在 Android 上,WorkManager 是常用选择,因为它能在稍后重试并在应用重启后继续。把 UI 操作保持轻量,必要时把长时间任务交给后台作业。

在移动端安全的缓存规则

将规则转为 API
以可视化方式设计数据模型与 API 端点,然后在客户端之间发布一致行为。
创建 API

缓存能在慢速网络上带来巨大收益:减少重复下载,让界面感觉瞬时响应。但如果在错误场景下显示过期数据(例如旧余额或过期的送货地址),也会造成问题。

稳妥的做法是只缓存用户可以容忍略微过时的数据,并对任何影响金额、安全或最终决定的数据强制进行实时检查。

可依赖的 Cache‑Control 基本规则

大多数规则归结为几个头:

  • max-age=60:在 60 秒内可以重用缓存响应而不询问服务器。
  • no-store:不要保存该响应(适用于令牌和敏感屏幕)。
  • must-revalidate:如果已过期,必须先向服务器确认再使用。

在移动端,must-revalidate 可以防止在短暂离线后“默默错误”的数据出现。如果用户在地铁出来后打开应用,你希望界面快速,但也希望应用确认数据是否仍然有效。

ETag 验证:快速、廉价且可靠

对于读取端点,基于 ETag 的验证通常优于长时间的 max-age。服务器返回响应时带上 ETag,下次应用发送 If-None-Match。如果未变更,服务器返回 304 Not Modified,这在弱网络上非常小且快速。

这对商品列表、个人资料详情和设置屏都很合适。

简单经验法则:

  • 对读取端点使用短 max-agemust-revalidate,并尽可能支持 ETag
  • 不要缓存写入端点(POST/PUT/PATCH/DELETE)。把它们视为始终需要网络的操作。
  • 对任何敏感信息(鉴权响应、支付步骤、私信)使用 no-store
  • 对静态资源(图标、公开配置)缓存时间长一些,因为陈旧的风险低。

在整个应用中保持缓存决策的一致性。用户更容易注意到不一致,而不是小的延迟差异。

在不让情况更糟的前提下安全重试

为不稳定网络构建
从一开始就为不稳定网络制定明确的重试与超时规则,构建移动应用和后端。
试用 AppMaster

重试看起来像是简单的补救手段,但如果用错了会适得其反。错误请求被重试会增加负载、消耗电量,并让应用看起来卡住。

先只重试那些很可能是暂时性的失败。连接掉线、读取超时或短暂的服务器中断在下一次尝试时可能成功。错误密码、缺失字段或 404 则不会。

实用规则集:

  • 重试超时和连接失败。
  • 重试 502、503,有时重试 504。
  • 不要重试 4xx(除非是 408 或 429 且你有明确的等待规则)。
  • 不要重试那些已经到达服务器并可能正在处理的请求。
  • 保持重试次数低(通常 1–3 次)。

指数退避 + 抖动:减少重试风暴

如果大量用户遇到同一故障,瞬时重试会形成流量波动,影响恢复。使用指数退避(每次等待更久)并加入抖动(小幅随机延迟),避免设备同步重试。

例如:第一次等待约 0.5s,第二次 1s,第三次 2s,每次加减约 20% 的随机抖动。

对总重试时间设上限

如果没有限制,重试可能把用户困在加载中几分钟。为整个操作(包括所有等待)设定最大总时长。许多应用的目标是在 10–20 秒内给出最终结果或明确失败。

同时要根据上下文调整:如果用户在提交表单,他们希望尽快得到答复;如果是后台同步失败,可以稍后重试。

永远不要对非幂等的写操作(如下单、付款)自动重试,除非你有幂等键或服务器端的去重检查。如果不能保证安全,就明确失败并让用户决定下一步。

针对关键操作的重复预防

在慢或不稳定的连接下,用户会双击。系统可能在后台重试。你的应用可能在超时后重发。如果这个操作是“创建某物”(下单、转账、改密码),重复会带来严重后果。

幂等性意味着同样的请求应产生相同的结果。当请求被重复时,服务器不应生成第二个订单,而应返回第一次的结果或表示“已完成”。

对每次关键尝试使用幂等键

对关键操作,在用户开始尝试时生成一个唯一的幂等键,并随请求发送(常见做法是在请求头中加 Idempotency-Key,或在请求体中带一个字段)。

实用流程:

  • 用户点“支付”时生成一个 UUID 幂等键。
  • 本地保存一条小记录:status = pending,createdAt,request payload 的哈希等。
  • 携带该键发送请求。
  • 收到成功响应后,将 status 标记为 done,并保存服务器返回的结果 ID。
  • 如果需要重试,重用同一个键,而不是生成新键。

“重用同一个键”这条规则能阻止意外的重复扣款。

处理应用重启与离线间隙

如果应用在请求中途被杀掉,下次启动仍需保证安全。把幂等键和请求状态保存在本地持久存储(例如一个小的数据库行)。重启后,要么用相同的键重试,要么用保存的键或服务器结果 ID 调用“查询状态”的端点。

在服务器端,契约应当明确:收到重复键时应拒绝第二次尝试或返回原始响应(相同的订单 ID、相同的收据)。如果服务器还做不到这一点,客户端的重复预防永远无法完全可靠,因为客户端看不到请求发送后服务器端到底发生了什么。

给用户的一个友好处理:如果尝试处于待定状态,显示“支付进行中”并禁用按钮,直到收到最终结果。

能减少误再次提交的 UI 模式

添加鉴权与支付
使用内置模块(例如身份认证和 Stripe 支付),把原型变成真实工作流。
开始使用

慢速连接不仅会破坏请求,还会改变用户的点击行为。当屏幕卡住两秒,很多用户会认为没反应并再次点击。你的 UI 必须让“一次点击”在网络不佳时也能感觉可靠。

当操作可撤销或风险低(收藏、保存草稿、标记已读)时,乐观更新较安全;对于付款、库存、不可逆删除以及可能造成重复的场景,应采用确认式 UI。

关键操作的一个好默认做法是明确的待处理状态:第一次点击后立刻把主按钮切换为“提交中…”,禁用该按钮,并显示简短的说明正在发生什么。

在不稳定网络下有效的模式包括:

  • 点击后禁用主操作按钮,直到有最终结果。
  • 显示可见的“待处理”状态并包含细节(金额、收款人、商品数量)。
  • 增加“最近活动”视图,让用户确认已发送的内容。
  • 如果应用被切到后台,恢复时保持待处理状态。
  • 优先使用一个明确的主按钮,而不是在同一屏幕上放多个可点目标。

有时请求成功但响应丢失。把它当作正常结果而不是鼓励重复点击的错误。不要直接显示“失败,请重试”,而是显示“我们还不确定结果”,并提供安全的下一步(例如“检查状态”)。如果无法检查状态,保留本地的待处理记录并告知用户连接恢复后会更新。

让“重试”明确且安全。只有在你能用相同的客户端请求 ID 或幂等键重复请求时,才显示重试选项。

真实示例:不稳定的结账提交流程

自信地部署
将应用部署到 AppMaster Cloud 或你自己的云中,并提供匹配真实移动条件的配置。
立即开始

一位顾客在信号断断续续的列车上结账。应用既要有耐心,又不能下两个订单。

一个安全的顺序如下:

  1. 应用在客户端生成尝试 ID,并把结账请求连同幂等键(例如存于购物车的 UUID)一起发送。
  2. 请求等待明确的连接超时,然后是更长的读取超时。列车进隧道,调用超时。
  3. 应用在短暂延迟后重试一次,但仅在从未收到服务器响应的前提下重试。
  4. 服务器收到第二次请求并发现相同的幂等键,便返回原始结果而不是创建新订单。
  5. 应用在收到成功响应(哪怕是来自重试)后显示最终确认页。

缓存遵循严格规则。商品列表、送货选项和税表可短期缓存(GET 请求)。结账提交(POST)永远不缓存。即便使用 HTTP 缓存,也应把它当作浏览时的只读辅助,而不是能“记住”一次支付结果的机制。

重复预防既是网络策略也是 UI 决策。用户点“支付”时按钮被禁用,屏幕显示“提交订单…”并提供一个取消选项。如果丢失网络,界面切为“仍在尝试”,并保留同一个尝试 ID。如果用户强制关闭后重新打开,应用应使用该 ID 去查询订单状态,而不是让用户再次付款。

快速检查清单与下一步

如果你的应用在公司 Wi‑Fi 下看起来“基本没问题”,但在通勤、楼梯间或偏远地区就崩溃,那就把这当作发布门槛。这项工作更多是关于可重复的清晰规则,而不是花哨的代码。

发布前的清单:

  • 为不同类型端点设置超时(登录、列表、上传、结账),并在限速和高延迟网络上测试。
  • 仅在确实安全的场景下重试,并用退避机制限制重试次数(读取通常可重试几次,写入通常不重试)。
  • 为每个关键写操作(支付、下单、表单提交)添加幂等键,防止重试或双击造成重复。
  • 明确缓存规则:哪些可以过期使用、哪些必须实时、哪些绝不缓存。
  • 让状态可见:待处理、失败与完成应有不同展现,且应用重启后应记住已完成的动作。

如果其中任何一项的答复是“我们以后再决定”,屏幕间就会出现随机行为。

让规则落地的下一步

写一页长的网络策略:端点分类、超时目标、重试规则和缓存预期。在一个地方(拦截器、共享客户端工厂或一个小包装器)强制执行它,这样每个团队成员默认都会得到相同的行为。

然后做一次简短的重复性演练。选一个关键操作(比如结账),模拟转圈冻结、强制关闭应用、切换飞行模式并再次点击。如果你无法证明它是安全的,用户最终会找到破绽。

如果你想在后端和客户端之间实现相同规则而无需到处手工连接,AppMaster (appmaster.io) 可以通过生成可投入生产的后端与原生客户端源码来帮忙。即便如此,关键始终是策略:先定义幂等性、重试、缓存与 UI 状态,然后在整个流程中一致应用它们。

常见问题

在调整超时和重试之前,我首先应该做什么?

开始之前先为每个屏幕和操作定义什么是“正确”的行为,尤其是那些必须最多执行一次的操作(如支付或订单)。规则明确后,再去设置超时、重试、缓存和 UI 状态,而不是依赖库的默认值。

在慢速或不稳定网络上,用户最常注意到哪些症状?

用户通常会看到永无止境的加载旋转、长时间后出现错误、第二次尝试才成功,或产生重复结果(如两个订单或双重扣款)。这些通常源于不清晰的重试与“进行中 vs 失败”规则,而不仅仅是信号差。

在移动端,我应该如何看待 connect、read、write 超时?

把 connect 用于建立连接的等待时间,write 用于发送请求体(上传),read 用于在发送完后等待响应。低风险的读取可以用较短超时,关键的提交则需要更长的读/写超时,并在 UI 层设置明确的上限,避免用户无止境等待。

如果我在 OkHttp 中只能设置一个超时,应该选哪个?

如果只能设置一项,使用 callTimeout 来限制整个操作的端到端时长,这样可以避免“无限”等待。之后再根据需要分层设置 connect/read/write 以获得更细粒度的控制,尤其是上传和慢响应场景。

哪些错误通常可以安全重试,哪些不应重试?

优先重试那些很可能是瞬时问题的失败:连接断开、DNS 问题和超时,有时还包括 502/503/504。避免重试 4xx(除非是 408 或 429 并且你有明确等待策略)。不要自动重试已经到达服务器并可能正在处理的请求,除非有幂等性保护。

如何在不让应用看起来卡住的情况下加入重试?

用少量重试(通常 1–3 次)、指数退避并加入少量随机抖动,避免大量设备同时重试造成流量风暴。同时对重试的总耗时设上限,让用户能在短时间内得到明确结果,而不是被卡在长时间的加载中。

什么是幂等性,为什么它对支付和订单重要?

幂等性意味着重复同一请求不会产生第二个结果,因此双击或重试不会导致重复扣款或重复下单。对关键操作为每次尝试发送一个幂等键(idempotency key),在重试时重用该键,服务器则应返回最初的结果或标识为已完成。

我应该如何在 Android 上生成并存储幂等键?

在用户开始操作时生成唯一键,将其与一个小的“待处理”记录一同本地保存,并在请求中发送该键。重试或重启后重用同一个键,或者用保存的键去轮询状态,这样就不会把一次用户意图变成两次服务器写入。

在不可靠网络上,哪些缓存规则对移动应用最安全?

只缓存那些允许有点过期的数据,对金钱、安全或最终决策类数据强制刷新。读取端优先采用短期缓存加重验证,并考虑使用 ETag;写入请求(POST/PUT/PATCH/DELETE)不要缓存,敏感响应用 no-store

哪些 UI 模式能减少慢网络下的重复点击和误提交?

在第一次点击后禁用主按钮,立即显示“提交中”的状态,并保留一个可见的待处理状态,能在后台或重启后继续显示。如果响应可能丢失,不要直接提示“失败,请重试”,而是提示“不确定结果”,并提供检查状态等安全操作。

容易上手
创造一些 惊人的东西

使用免费计划试用 AppMaster。
准备就绪后,您可以选择合适的订阅。

开始吧