news 2026/10/26 18:25:36

Kotlin+OkHttp 手写 Android WebDAV 客户端与增量同步实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Kotlin+OkHttp 手写 Android WebDAV 客户端与增量同步实践

简介:这是一份面向Android开发者与远程文件管理需求用户的WebDAV协议客户端源码工程,采用Kotlin编写,可解决在移动端直接浏览、上传、下载、重命名、移动和删除远程服务器文件的问题,适合想学习Android网络编程、WebDAV协议实现或搭建私有文件管理工具的初中级开发者参考。压缩包共149个文件,约512KB,以png界面截图与xml布局资源、java与kt业务代码为主,辅以gradle构建脚本、properties配置与少量dart、yaml、json等文件,覆盖界面、网络请求、认证与异步处理等层次。工程中可见登录页、主界面与数据库辅助类等模块,体现了Basic/Digest认证、Retrofit或OkHttp网络通信、协程异步加载、权限申请与错误提示等关键实现思路,便于读者对照梳理WebDAV请求方法与文件操作接口的组织方式,也可作为二次开发与课程设计的起点。目前已有1790人浏览学习。

1. 从一根数据线说起:这个 Kotlin 写的 WebDAV 客户端解决什么问题

手上那台用了三四年的旧手机,相册里堆着几千张照片,插上数据线往电脑拷要等半小时,中途还容易断;发到聊天软件里又会被压成马赛克。如果你家里那台 NAS 或者公司内网的存储服务已经开好了 WebDAV 端口,那其实缺的不是一个网盘账号,而是一个能把 WebDAV 当成手机本地文件管理器来用的 Android 客户端。这个用 Kotlin 写的 WebDAVClient 做的就是这件事:服务器地址和账号填进去,目录树一层层列出来,上传、下载、新建文件夹、重命名、移动、删除都在手机上点完,协议层完全自己实现,没有引一个黑盒的 SDK 进来。它适合两类人:一类是想找个能连自己 NAS 的轻量客户端的普通用户,另一类是正在学 Android 网络层、想看看一个真实协议客户端怎么写分层的开发者。

2. 技术栈与目录结构:Kotlin + OkHttp 三层的落地版图

这个项目最值得先看的是它的技术选型,因为它决定了后面每一行代码长什么样。整个客户端没有用任何第三方 WebDAV 库,HTTP 层交给 OkHttp,XML 解析用 Android 自带的 XmlPullParser,异步用 Kotlin 协程加 StateFlow,UI 层是典型的 RecyclerView 加 Fragment。这套组合看起来朴素,但每一块都有明确的理由,不是为了炫技。

2.1 为什么不用现成的 WebDAV 库

Java 生态里做 WebDAV 客户端最出名的是 Sardine,功能确实全,但在 Android 上有几个绕不开的麻烦。它内部依赖javax.xml那一套注解和绑定机制,而 Android 运行时并没有完整实现这部分,直接引进来要么运行时崩,要么得额外塞一堆兼容包,APK 体积也跟着涨。更麻烦的是它的 API 是同步阻塞式的,套进协程里得自己包withContext,调用链上的异常也不好统一收口。

我一般会这么判断:如果项目只需要 WebDAV 的常用方法,也就是 PROPFIND 列目录、GET 下载、PUT 上传、MKCOL 建目录、MOVE 改名、DELETE 删除这六七个,那就完全没必要引一个几 MB 的库。WebDAV 本质上就是「HTTP 加几个自定义方法,再加一段约定格式的 XML」,OkHttp 天然支持自定义 method,XmlPullParser 又是流式的、解析大目录响应时内存占用很低,正好对得上手机这种内存紧张的环境。

所以这个项目的网络层结构就是:OkHttp 负责连接、认证头、超时、重定向,自己写一个DavClient把每个 WebDAV 方法包成一个挂起函数,再往上是一个 Repository 做缓存和错误翻译,最上面是 ViewModel 和 UI。职责切得很干净,出问题时能一眼定位到是哪一层。

2.2 目录结构:data / domain / ui 三层怎么切

源码包的组织方式基本是按层分,再在层内按职责分文件。下面这张表是我按项目实际结构整理的,看代码之前先对着它找文件会快很多:

包路径职责关键类
data/remote协议实现,只认 URL 和 XMLDavClient、DavException
data/model协议返回的数据结构DavEntry、ResourceType
data/repository缓存、错误翻译、并发控制DavRepository
domain路径运算、排序规则DavPath、EntrySorter
ui/list文件列表页FileListFragment、FileListAdapter
ui/transfer上传下载队列与进度TransferViewModel、ProgressRequestBody
ui/settings服务器配置与凭据存储ServerConfigStore

这么切的直接好处是,DavClient里看不到任何 Android UI 的东西,它只接收一个 path 字符串、返回一个List<DavEntry>,单元测试里拿一个假的 HTTP 响应就能跑。DavRepository才负责把服务器返回的错误码翻译成人话,比如 401 对应「账号密码不对」,404 对应「这个目录已经不在了」,507 对应「服务器空间满了」。UI 层只管把状态画出来,不关心底下是 HTTP 还是别的什么。

domain这一层很多人会省掉,觉得多余。但路径处理真的值得单独拎出来:WebDAV 的路径拼接、父目录计算、编码解码、判断是不是根目录,这些逻辑散落在各个 Fragment 里,早晚会出不一致的 bug。放进一个DavPath值对象里,全项目只有一处实现,改一次全对。

2.3 Gradle 依赖与网络配置

依赖清单很短,核心就四个。版本号不用照抄,跟你工程里现有的 AGP 和 Kotlin 版本对齐就行,原则是 OkHttp 不要低于 4.x,协程库跟 Kotlin 版本配套:

// app/build.gradle.kts dependencies { // HTTP 层,WebDAV 的自定义 method 全靠它 implementation("com.squareup.okhttp3:okhttp:4.12.0") // 协程 + Android 主线程调度器 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0") // ViewModel 的 viewModelScope implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") // 列表 implementation("androidx.recyclerview:recyclerview:1.3.2") // 选本地文件做上传源,比手写 SAF 省事 implementation("androidx.documentfile:documentfile:1.0.1") } android { compileSdk = 34 defaultConfig { minSdk = 24 // 24 以下要额外处理 TLS 和 FileProvider,没必要 targetSdk = 34 } }

还有一个容易漏的配置。很多人自建的 WebDAV 服务跑在内网,地址是http://192.168.x.x:5005这种明文 HTTP,Android 9 以后默认禁止明文流量,不配置的话请求会直接失败,而且报错信息很含糊,看起来像是连不上服务器。解决办法是在 manifest 的 application 节点上放开:

<!-- AndroidManifest.xml --> <application android:allowBackup="false" android:networkSecurityConfig="@xml/network_security_config" android:usesCleartextTraffic="true"> <!-- 其余略 --> </application>

usesCleartextTraffic="true"是全局放开,方便调试,但正式发布前建议改成networkSecurityConfig只对特定域名放开。这个项目里两种都写了,配置文件里给内网网段单独开了白名单,其余域名仍然强制 HTTPS。参数上要注意的是networkSecurityConfig的优先级高于usesCleartextTraffic,两个同时存在时以配置文件为准,这一点在后面第 5 章的自签证书排查里还会用到。

3. 协议核心拆解:PROPFIND 请求与 XML 解析这条主链路

整个客户端最核心的一条链路是「列目录」,走的是 WebDAV 的 PROPFIND 方法。它跟普通 GET 的区别在于:请求体是一段 XML,告诉服务器「我要哪些属性」;响应是一个 207 Multi-Status,里面每个<d:response>节点对应一个文件或目录。这条链路写对了,增删改基本是照葫芦画瓢;写错了,后面所有页面都是空白。

3.1 用 OkHttp 发一个 PROPFIND

OkHttp 的Request.Builder提供了get()、post()、delete()这些快捷方法,但 PROPFIND、MKCOL、MOVE 这些不在其中,必须用method(name, body)这个重载。这里有个细节:PROPFIND 通常带请求体,所以传一个非空的 RequestBody 是安全的;而 MKCOL 按规范可以不带 body,那就得传null,否则 OkHttp 会因为你给了一个不该有 body 的方法塞 body 而抛异常。

class DavClient( private val baseUrl: String, // 例如 https://your-dav-host/dav/ private val user: String, private val pass: String ) { private val http = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) // 目录大的时候读超时要留够 .writeTimeout(30, TimeUnit.SECONDS) .build() suspend fun list(path: String): List<DavEntry> = withContext(Dispatchers.IO) { // resolve 内部用 HttpUrl 拼装,避免手写斜杠和二次编码 val url = resolve(path) val body = """ <?xml version="1.0" encoding="utf-8"?> <d:propfind xmlns:d="DAV:"> <d:prop> <d:displayname/> <d:getcontentlength/> <d:getlastmodified/> <d:resourcetype/> <d:getcontenttype/> </d:prop> </d:propfind> """.trimIndent().toRequestBody("application/xml; charset=utf-8".toMediaType()) val request = Request.Builder() .url(url) .header("Authorization", Credentials.basic(user, pass)) .header("Depth", "1") // 1 表示只列当前层,别用 infinity .method("PROPFIND", body) // 自定义 method 必须走这个重载 .build() http.newCall(request).execute().use { resp -> if (!resp.isSuccessful) throw DavException(resp.code, resp.message) val stream = resp.body?.byteStream() ?: throw DavException(-1, "empty body") parseMultiStatus(stream, url) } } }

请求体里的五个属性各有用途:displayname是给人看的名字,某些服务器上它和 href 的最后一段并不一致;getcontentlength决定列表里显示的文件大小,目录一般返回 0;getlastmodified用来排序和后面做增量同步;resourcetype里只要出现<d:collection/>子节点就说明这是目录,这是判断目录的唯一可靠方式;getcontenttype用来给列表项配图标。

Depth头是最容易设错的一个参数。按 RFC 规定它可以是 0、1 或 infinity,0表示只要这个资源本身的属性,1表示这一层加它的直接子项,infinity表示递归整棵子树。列目录当然用1。有些教程为了「一次拿到全部」会写 infinity,在文件少的测试服务器上看不出问题,一旦目录里有几千个文件,服务器要遍历整棵树,响应可能几百 KB 到几 MB,客户端这边读超时先炸,服务器那边也可能因为超时半途断开,返回一段截断的 XML。这就是后面第 5 章要讲的超时坑的源头。

3.2 XmlPullParser 解析 multistatus

响应体长这样,注意命名空间前缀是d:,但有些服务器会用D:或者别的字母:

<?xml version="1.0" encoding="utf-8"?> <d:multistatus xmlns:d="DAV:"> <d:response> <d:href>/dav/photos/</d:href> <d:propstat> <d:prop> <d:resourcetype><d:collection/></d:resourcetype> <d:displayname>photos</d:displayname> </d:prop> <d:status>HTTP/1.1 200 OK</d:status> </d:propstat> </d:response> <d:response> <d:href>/dav/photos/a.jpg</d:href> <d:propstat> <d:prop> <d:resourcetype/> <d:getcontentlength>1048576</d:getcontentlength> </d:prop> </d:propstat> </d:response> </d:multistatus>

解析这段 XML 有三个关键点:必须开启命名空间处理,否则不同前缀的服务器会全部解析失败;必须以</d:response>为一条记录的边界,不能按标签逐个往外丢;返回结果里的第一条通常是请求的目录自己,要过滤掉,否则列表里会出现一个「自己」。

private fun parseMultiStatus(input: InputStream, selfUrl: HttpUrl): List<DavEntry> { val parser = Xml.newPullParser() // 开了命名空间之后 parser.name 不含前缀,parser.namespace 才是 "DAV:" parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) parser.setInput(input, "UTF-8") val result = mutableListOf<DavEntry>() var href = "" var isDir = false var size = 0L var modified = 0L var name = "" var event = parser.eventType while (event != XmlPullParser.END_DOCUMENT) { if (event == XmlPullParser.START_TAG && parser.namespace == "DAV:") { when (parser.name) { "href" -> href = parser.nextText() "collection" -> isDir = true // 空标签,出现即目录 "getcontentlength" -> size = parser.nextText().toLongOrNull() ?: 0L "getlastmodified" -> modified = parseHttpDate(parser.nextText()) "displayname" -> name = parser.nextText() } } if (event == XmlPullParser.END_TAG && parser.namespace == "DAV:" && parser.name == "response") { val entry = toEntry(href, name, isDir, size, modified, selfUrl) if (entry != null) result += entry // 每条记录结束必须重置,否则字段会串到下一条上 href = ""; isDir = false; size = 0L; modified = 0L; name = "" } event = parser.next() } return result }

toEntry这个函数负责把 href 变成一个可以直接用的数据对象,里面有两个必须做的转换。第一是 URL 解码,服务器返回的 href 里中文和空格是百分号编码的,%E7%85%A7%E7%89%87这种,不URLDecoder.decode的话列表上就是一堆乱码。第二是判断「是不是自己」,把 href 转成绝对路径后和selfUrl.encodedPath比对,相同就丢掉。另外要注意 href 有可能是完整 URL(https://host/dav/a.jpg)也可能只是绝对路径(/dav/a.jpg),这两种格式都允许,所以解析时先看有没有 scheme,没有就拼上 baseUrl。

getlastmodified的格式是 HTTP 日期,形如Wed, 21 Oct 2015 07:28:00 GMT,用SimpleDateFormat配Locale.US和GMT时区解析。这里不要用默认 Locale,某些地区设置下解析会失败,属于那种在模拟器上测不出来、到真机上才炸的玄学问题。

3.3 增删改:MKCOL、PUT、MOVE、DELETE 的封装

列目录写通了,其余操作都是同一个模式,难点集中在请求头和方法语义上:

// 建目录:MKCOL 不需要 body,成功返回 201 suspend fun mkdir(path: String) = withContext(Dispatchers.IO) { val req = Request.Builder() .url(resolve(path)) .header("Authorization", Credentials.basic(user, pass)) .method("MKCOL", null) // 传 null,OkHttp 会拒绝给不该有 body 的方法塞 body .build() http.newCall(req).execute().use { check(it) } } // 重命名 / 移动:MOVE,目标路径放在 Destination 头里,必须是完整的绝对 URL suspend fun move(from: String, to: String) = withContext(Dispatchers.IO) { val req = Request.Builder() .url(resolve(from)) .header("Authorization", Credentials.basic(user, pass)) .header("Destination", resolve(to).toString()) // 必须绝对 URL,且已编码 .header("Overwrite", "F") // F = 目标存在就失败,T = 覆盖 .method("MOVE", null) .build() http.newCall(req).execute().use { check(it) } }

Destination头是 MOVE 和 COPY 最容易翻车的地方。规范要求它是完整的绝对 URL,不能只写一个相对路径;而且它必须是已经编码过的形式,如果你先URLEncoder.encode整个 URL 再塞进去,https://里的冒号和斜杠也会被编码掉,服务器直接返回 400。正确做法是用HttpUrl的 builder 逐段添加,让它自己处理编码。Overwrite头也值得显式设置,默认值各服务器实现不一致,有的默认覆盖有的默认失败,写死F然后在 UI 层弹一个「目标已存在,是否覆盖」的确认框,行为最可控。

HTTP 状态码到用户提示的映射建议集中写在一个地方,散在各处很容易出现同一个错误在不同页面提示不一样的情况:

状态码含义客户端提示
401认证失败账号或密码不正确,请到设置里重新填写
403无权限这个目录你没有写入权限
404资源不存在目录已被删除或重命名
405方法不允许服务器禁用了该操作(常见于只读挂载)
409父目录不存在上级目录已不存在,请刷新后重试
507存储空间不足服务器空间已满

4. 文件浏览与传输:列表、上传、下载的协程化实现

协议层跑通之后,接下来是把它接到 UI 上,并且把上传下载这两个耗时操作做成不卡界面、能看进度、能取消的形态。这一章的三块内容凑起来,就是用户实际能感知到的全部体验。

4.1 Repository 与 ViewModel 的数据流

数据从DavClient出来之后不直接进 Fragment,中间隔一层 Repository 和一个 ViewModel。这么做的价值在列表页特别明显:用户在多层目录之间来回点,如果每次返回都重新发一遍 PROPFIND,体验会很差,加上一层短时内存缓存,返回上一级就是秒开。

class DavRepository(private val client: DavClient) { // 路径 -> 条目列表,用 LinkedHashMap 做个简易 LRU private val cache = object : LinkedHashMap<String, List<DavEntry>>(16, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, List<DavEntry>>) = size > 32 } suspend fun list(path: String, force: Boolean = false): List<DavEntry> { if (!force) cache[path]?.let { return it } val entries = client.list(path).sortedWith(EntrySorter.default) cache[path] = entries return entries } // 任何写操作之后都要让相关目录的缓存失效 fun invalidate(path: String) { cache.remove(path) cache.remove(DavPath.parentOf(path)) } }

ViewModel 那边用 StateFlow 暴露一个密封类状态,UI 只做when分支渲染。这样旋转屏幕、切换深色模式这些会导致重建的场景里,请求不会重发,因为 ViewModel 活得比 Fragment 长:

sealed interface UiState { data object Loading : UiState data class Success(val entries: List<DavEntry>) : UiState data class Error(val message: String) : UiState } class FileListViewModel(private val repo: DavRepository) : ViewModel() { private val _state = MutableStateFlow<UiState>(UiState.Loading) val state: StateFlow<UiState> = _state.asStateFlow() fun load(path: String, refresh: Boolean = false) = viewModelScope.launch { if (refresh) _state.value = UiState.Loading runCatching { repo.list(path, refresh) } .onSuccess { _state.value = UiState.Success(it) } .onFailure { _state.value = UiState.Error(it.toUserMessage()) } } }

viewModelScope的好处是页面销毁时协程自动取消,用户点进一个特别深的目录又马上退出来,那个还没回来的 PROPFIND 会被取消掉,不会在后台占着连接。runCatching包住整个调用,DavException和IOException都走同一条错误路径,UI 层不用关心异常类型。

4.2 上传:自定义 RequestBody 做进度回调

PUT 上传看起来简单,file.asRequestBody()一行就完了,但那样拿不到进度。要显示进度就得自己实现一个RequestBody,在writeTo里分段写、每写一段回调一次:

class ProgressRequestBody( private val file: File, private val contentType: MediaType?, private val onProgress: (sent: Long, total: Long) -> Unit ) : RequestBody() { override fun contentType() = contentType override fun contentLength() = file.length() // 必须给,否则服务器可能拒绝或走 chunked override fun writeTo(sink: BufferedSink) { val total = file.length() var uploaded = 0L file.source().use { source -> while (true) { val read = source.read(sink.buffer, 8 * 1024L) // 8KB 一段,小文件也不会太碎 if (read == -1L) break uploaded += read sink.emitCompleteSegments() // 关键:把缓冲区真正刷到 socket onProgress(uploaded, total) } } } }

三个参数要点:contentLength()必须返回真实长度,返回 -1 会走 chunked 编码,部分服务器不接受 chunked 的 PUT;缓冲区读大小 8KB 是个折中,太小回调过于频繁会拖慢传输,太大进度条看起来一跳一跳;emitCompleteSegments()这一句不能省,只往sink.buffer里写而不刷,数据会一直堆在内存里,表现就是进度卡在 0 然后突然变成 100,再大一点就直接 OOM。这个坑在第 5 章还会单独拎出来说。

进度回调最终要回到主线程,ViewModel 里用Dispatchers.Main或者把回调包在withContext里,别在 IO 线程直接改 UI 状态,虽然 StateFlow 本身是线程安全的,但频繁跨线程更新会造成界面抖动。

4.3 下载:用 Range 头做断点续传

下载比上传多一层考虑:网络断了之后能不能接着下。WebDAV 支持标准 HTTP 的Range头,实现方式不难,但有一个必须处理的边界:

suspend fun download(path: String, target: File, onProgress: (Long, Long) -> Unit) = withContext(Dispatchers.IO) { val start = if (target.exists()) target.length() else 0L val req = Request.Builder() .url(resolve(path)) .header("Authorization", Credentials.basic(user, pass)) .apply { if (start > 0) header("Range", "bytes=$start-") } .build() http.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw DavException(resp.code, resp.message) // 服务端不支持 Range 时返回 200 而不是 206,此时必须从头写,否则文件会拼接重复 val append = resp.code == 206 val total = resp.body!!.contentLength() + if (append) start else 0L FileOutputStream(target, append).use { out -> val buf = ByteArray(64 * 1024) var written = if (append) start else 0L resp.body!!.byteStream().use { input -> while (true) { val n = input.read(buf) if (n == -1) break out.write(buf, 0, n) written += n onProgress(written, total) } } } } }

resp.code == 206这个判断是整个断点续传的正确性开关。有的服务器(或者中间的反向代理)不理Range头,老老实实返回 200 加全量内容,这时候如果你按追加模式打开文件,最终得到的文件就是「前半段 + 完整文件」,大小看起来对不上,图片打不开。所以看到 200 必须改成覆盖写。另外total的计算也要跟着分情况,返回 206 时contentLength只是剩余部分,得加上已经下载的start才是真实总大小。

4.4 列表页的路径栈与返回逻辑

UI 层没有用系统返回键的默认行为,而是自己维护了一个路径栈。用户点进目录时把当前路径 push 进去,按返回键时 pop,栈空了才退出页面。这样做的原因是 Android 的 Fragment 返回栈和 WebDAV 的目录层级并不是一一对应的,用系统栈管会出现「退出一层却回到两层前」的错位。

底部再放一个面包屑,把路径按/拆开渲染,点任意一级直接跳过去。这个功能看着不起眼,但从五层深的相册目录里退出来,比连按五次返回键舒服太多。路径字符串本身交给DavPath处理,拼接、取父级、判断根目录都在那里,UI 层只拿结果。

5. 常见问题排查:401、编码、证书这几类翻车现场

这一章的内容基本都是我在别的项目里踩过、在这个项目的 issue 里也反复看到的。症状看起来五花八门,归根到底就那么几种。

5.1 一进列表就弹认证框,输了密码还是弹

现象:填完账号密码进列表页,系统弹出一个 HTTP 认证对话框,输对了也继续弹,能弹三五次然后报 401。

原因:同时用了 OkHttp 的Authenticator和手动加Authorization头。请求带上手动头之后服务器返回 401,Authenticator被触发,它拿不到正确凭据又重试一次,形成循环。

解决:二选一。这个项目里选手动头,因为账号密码是用户在设置页填的、存在本地,不需要系统弹窗介入。构造 OkHttpClient 时不设置authenticator(),所有请求自己加Authorization: Basic xxx。Credentials.basic()每次调用都会重新做一次 Base64,注意密码里如果有中文,要显式指定StandardCharsets.UTF_8,默认字符集在不同 ROM 上可能不一致。

5.2 中文目录名或带空格的文件名,一点就 404

现象:列表能正常显示「我的照片」这种中文目录,点进去报 404;文件名里带#、+、空格的时候也一样。

原因:拼接 URL 时没有做编码,或者做了两次编码。#在 URL 里是片段分隔符,不编码的话后面的内容根本不会发给服务器;而+在 query 里表示空格,在 path 里却是个普通字符,编码规则不一样。

解决:所有 URL 一律走HttpUrl.Builder的addPathSegments(),它会按 path 段做正确的百分号编码。服务器返回的 href 是已经编码过的,解析时先判断是否包含%,包含就URLDecoder.decode(value, "UTF-8")得到显示名,但拼请求 URL 时仍然用原始的编码形式,不要拿解码后的字符串再去拼。

5.3 自签 HTTPS 连不上,日志里只有一行 SSLHandshakeException

现象:内网服务器用的是自签证书,请求直接失败,异常信息里看不出所以然。

原因:Android 的默认TrustManager只信任系统 CA,自签证书不在其中。

解决:不建议图省事写一个 trust-all 的TrustManager,那等于把整个 App 的 HTTPS 校验关掉,是血泪经验级别的错误。正确做法是在network_security_config.xml里针对特定域名信任用户证书,或者把服务器证书的指纹写进配置做 pinning:

<!-- res/xml/network_security_config.xml --> <network-security-config> <domain-config cleartextTrafficPermitted="true"> <domain includeSubdomains="false">192.168.1.10</domain> </domain-config> </network-security-config>

再强调一次前面提过的优先级问题:只要写了networkSecurityConfig,usesCleartextTraffic就基本失效了,所有规则以配置文件为准。很多人两个都改了一通还是连不上,就是卡在这。

5.4 上传大文件进度卡死或者直接崩

现象:几 MB 的文件正常,上百 MB 的上传进度条卡在某个百分比不动,再等一会儿 App 被系统杀掉。

原因:writeTo里只往sink.buffer写数据,没有调用emitCompleteSegments(),OkHttp 的缓冲区没有及时刷到 socket,内存越堆越多,最后 OOM。

解决:每写完一段就调一次emitCompleteSegments(),并且把读取缓冲区控制在 8KB 到 64KB 之间。另外建议在 OkHttpClient 上把writeTimeout设大一点,默认 10 秒对于慢速网络下的 200MB 文件是不够的,中途超时断开又没做续传的话只能从头再来。上传之前先查一下Content-Length和服务器剩余空间,507 这个状态码提前拦掉,比传到一半失败体验好。

5.5 PROPFIND 超时,返回的 XML 解析到一半报错

现象:小目录正常,某个目录一进就转圈,最后提示超时;偶尔能拿到响应,但解析抛XmlPullParserException。

原因:Depth 头被写成了infinity,服务器遍历整棵子树,响应体巨大,读完之前连接就超时了;即使读到了,某些服务器在超时中断时会返回截断的 XML,标签都不闭合,解析器自然报错。

解决:Depth 固定写1,需要展示子目录内容就让用户点进去再请求一次。对于确实有几千个文件的目录,把readTimeout提到 60 秒,同时在 ViewModel 里加一个手动取消的入口。解析端再加一层兜底:try/catch包住解析循环,已经解析出来的条目先返回,不因为最后一段坏掉而整页空白。

6. 二次开发:给客户端补一套增量同步与离线缓存

拿到这个项目之后,最值得动刀的地方不是 UI,而是把「每次进目录都全量拉一遍」换成增量同步。做法也不复杂:本地建一张表记录每个文件的路径、大小、修改时间和本地状态,每次列目录时把服务器返回的List<DavEntry>和本地记录做一次 diff,只有变化的才动。

同步状态我建议就用四个值,够用且好判断:

状态含义触发条件
SYNCED本地与服务器一致大小和修改时间都相同
REMOTE_NEW服务器新增本地无记录,远端有
REMOTE_CHANGED服务器修改大小或修改时间不同
LOCAL_ONLY仅本地存在本地有记录,远端没有

diff 的核心就是两次集合比对,逻辑写在一个纯函数里,方便单测:

fun diff(remote: List<DavEntry>, local: List<SyncRecord>): List<SyncAction> { val localMap = local.associateBy { it.path } val actions = mutableListOf<SyncAction>() remote.forEach { r -> val l = localMap[r.path] when { l == null -> actions += SyncAction.Download(r.path) // 远端新增 l.size != r.size || l.modified != r.modified -> actions += SyncAction.Download(r.path) // 远端变了 else -> actions += SyncAction.Keep(r.path) // 无变化 } } // 本地有、远端没有的,先标记不删,交给用户确认,避免误删 local.filter { it.path !in remote.map { r -> r.path } } .forEach { actions += SyncAction.MarkMissing(it.path) } return actions }

有一个判断我建议你照抄:本地有、远端没有的文件不要自动删。WebDAV 服务器上的文件可能只是被临时移走了,也可能是别人正在整理,自动删除本地文件是不可逆操作,属于那种一旦发生就没法解释的事故。标成MarkMissing,在列表里用灰色显示,让用户自己决定。

验证方法也很直接:拿一台测试服务器,建一个两百个文件、五层深的目录,先全量同步一次记下耗时,然后在服务器上改三个文件、删一个、加一个,再跑一次增量,正常应该只有四个请求甚至更少,耗时降到原来的几十分之一。这个对比数据比任何说明都有说服力。

还有个进阶方向是给列表加本地缩略图缓存,图片文件下载过一次之后按路径做 key 存到 cache 目录,列表滚动时直接读本地,滑起来会顺很多。但注意要设一个总容量上限并做 LRU 清理,不然用户同步完一个相册目录,手机存储就被吃掉了。

从那以后我每次给这类客户端加同步功能,都会先把 diff 函数单独抽出来写成纯函数、配一组假的远端和本地数据跑单测,等它绿了再接 UI。顺序反过来,一旦出问题你分不清是比对逻辑错了还是界面状态错了,排查成本翻好几倍。希望这个思路帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 18:25:31

LSTM多输入多输出时间序列建模:变量对齐与步长解耦设计

简介&#xff1a;本资源是一套面向深度学习初学者与时间序列建模实践者的LSTM多输入多输出预测完整实现方案&#xff0c;聚焦空气质量、污染指数等多变量时序预测场景&#xff0c;适用于人工智能、机器学习课程设计及工业时序分析入门项目。压缩包含126个文件&#xff0c;主体为…

作者头像 李华
网站建设 2026/10/26 18:25:28

探地雷达数据处理:从原始回波到地质剖面的处理链与避坑指南

简介&#xff1a;这份资源是一套面向地质探测与信号处理方向学习者的探地雷达数据处理程序源码工程&#xff0c;适合具备一定编程基础、希望理解GPR数据解析流程的高年级学生和工程人员参考。压缩包共94个文件&#xff0c;约12.06MB&#xff0c;以17个h头文件与15个cpp源文件为…

作者头像 李华
网站建设 2026/10/26 18:25:27

Cocos Creator安卓头像上传全链路:相机相册裁剪与JS桥接实战

简介&#xff1a;面向Android与Cocos Creator开发者的图片交互实战资源&#xff0c;聚焦调用系统相机与相册、裁剪图片并完成上传下载的完整链路&#xff0c;适合需要实现头像上传、图片采集等功能的初中级开发者参考。内容覆盖运行时权限申请、Intent启动相机与相册、onActivi…

作者头像 李华
网站建设 2026/10/26 18:23:36

用过才敢说!2026年最值得拥有的专业AI论文工具

一、综合实力 TOP 榜&#xff08;2026 实测&#xff09; 1. 千笔AI&#xff08;4.8 星&#xff5c;预算友好型全流程王者&#xff09; 核心定位&#xff1a;国内学术全流程 AI 写作 降重 查重一体化平台核心优势&#xff1a;语义级降重&#xff0c;重复率 45%→8.77%&#…

作者头像 李华
网站建设 2026/10/26 18:20:54

Vue知识点3-响应式+组合式

这里点击改名按钮&#xff0c;会触发methods里定义的changeName函数&#xff0c;会立刻改变页面中name的值<template><div class"person"><h1>姓名:{{ name }}</h1><h1>年龄:{{ age }}</h1><!--点击事件用click --><bu…

作者头像 李华
网站建设 2026/10/26 18:20:32

【Linux】29:线程池的实现

目录 一、线程池设计 二、代码 2.1 Mutex.hpp 2.2 Cond.hpp 2.3 Thread.hpp 2.4 Log.hpp 2.5 Task.hpp 2.6 threadpool.hpp 2.7 main.cc 三、线程安全的单例模式 3.1 什么是单例模式 3.2 单例模式的特点 3.3 饿汉实现方式和懒汉实现方式 3.4 饿汉实现单例模式 3.…

作者头像 李华