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 层交给 OkHttpXML 解析用 Android 自带的 XmlPullParser异步用 Kotlin 协程加 StateFlowUI 层是典型的 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 天然支持自定义 methodXmlPullParser 又是流式的、解析大目录响应时内存占用很低正好对得上手机这种内存紧张的环境。所以这个项目的网络层结构就是OkHttp 负责连接、认证头、超时、重定向自己写一个DavClient把每个 WebDAV 方法包成一个挂起函数再往上是一个 Repository 做缓存和错误翻译最上面是 ViewModel 和 UI。职责切得很干净出问题时能一眼定位到是哪一层。2.2 目录结构data / domain / ui 三层怎么切源码包的组织方式基本是按层分再在层内按职责分文件。下面这张表是我按项目实际结构整理的看代码之前先对着它找文件会快很多包路径职责关键类data/remote协议实现只认 URL 和 XMLDavClient、DavExceptiondata/model协议返回的数据结构DavEntry、ResourceTypedata/repository缓存、错误翻译、并发控制DavRepositorydomain路径运算、排序规则DavPath、EntrySorterui/list文件列表页FileListFragment、FileListAdapterui/transfer上传下载队列与进度TransferViewModel、ProgressRequestBodyui/settings服务器配置与凭据存储ServerConfigStore这么切的直接好处是DavClient里看不到任何 Android UI 的东西它只接收一个 path 字符串、返回一个ListDavEntry单元测试里拿一个假的 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这种明文 HTTPAndroid 9 以后默认禁止明文流量不配置的话请求会直接失败而且报错信息很含糊看起来像是连不上服务器。解决办法是在 manifest 的 application 节点上放开!-- AndroidManifest.xml -- application android:allowBackupfalse android:networkSecurityConfigxml/network_security_config android:usesCleartextTraffictrue !-- 其余略 -- /applicationusesCleartextTraffictrue是全局放开方便调试但正式发布前建议改成networkSecurityConfig只对特定域名放开。这个项目里两种都写了配置文件里给内网网段单独开了白名单其余域名仍然强制 HTTPS。参数上要注意的是networkSecurityConfig的优先级高于usesCleartextTraffic两个同时存在时以配置文件为准这一点在后面第 5 章的自签证书排查里还会用到。3. 协议核心拆解PROPFIND 请求与 XML 解析这条主链路整个客户端最核心的一条链路是「列目录」走的是 WebDAV 的 PROPFIND 方法。它跟普通 GET 的区别在于请求体是一段 XML告诉服务器「我要哪些属性」响应是一个 207 Multi-Status里面每个d:response节点对应一个文件或目录。这条链路写对了增删改基本是照葫芦画瓢写错了后面所有页面都是空白。3.1 用 OkHttp 发一个 PROPFINDOkHttp 的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): ListDavEntry withContext(Dispatchers.IO) { // resolve 内部用 HttpUrl 拼装避免手写斜杠和二次编码 val url resolve(path) val body ?xml version1.0 encodingutf-8? d:propfind xmlns:dDAV: d:prop d:displayname/ d:getcontentlength/ d:getlastmodified/ d:resourcetype/ d:getcontenttype/ /d:prop /d:propfind .trimIndent().toRequestBody(application/xml; charsetutf-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决定列表里显示的文件大小目录一般返回 0getlastmodified用来排序和后面做增量同步resourcetype里只要出现d:collection/子节点就说明这是目录这是判断目录的唯一可靠方式getcontenttype用来给列表项配图标。Depth头是最容易设错的一个参数。按 RFC 规定它可以是 0、1 或 infinity0表示只要这个资源本身的属性1表示这一层加它的直接子项infinity表示递归整棵子树。列目录当然用1。有些教程为了「一次拿到全部」会写 infinity在文件少的测试服务器上看不出问题一旦目录里有几千个文件服务器要遍历整棵树响应可能几百 KB 到几 MB客户端这边读超时先炸服务器那边也可能因为超时半途断开返回一段截断的 XML。这就是后面第 5 章要讲的超时坑的源头。3.2 XmlPullParser 解析 multistatus响应体长这样注意命名空间前缀是d:但有些服务器会用D:或者别的字母?xml version1.0 encodingutf-8? d:multistatus xmlns:dDAV: d:response d:href/dav/photos//d:href d:propstat d:prop d:resourcetyped:collection//d:resourcetype d:displaynamephotos/d:displayname /d:prop d:statusHTTP/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:getcontentlength1048576/d:getcontentlength /d:prop /d:propstat /d:response /d:multistatus解析这段 XML 有三个关键点必须开启命名空间处理否则不同前缀的服务器会全部解析失败必须以/d:response为一条记录的边界不能按标签逐个往外丢返回结果里的第一条通常是请求的目录自己要过滤掉否则列表里会出现一个「自己」。private fun parseMultiStatus(input: InputStream, selfUrl: HttpUrl): ListDavEntry { val parser Xml.newPullParser() // 开了命名空间之后 parser.name 不含前缀parser.namespace 才是 DAV: parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) parser.setInput(input, UTF-8) val result mutableListOfDavEntry() 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 有可能是完整 URLhttps://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) // 传 nullOkHttp 会拒绝给不该有 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 : LinkedHashMapString, ListDavEntry(16, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntryString, ListDavEntry) size 32 } suspend fun list(path: String, force: Boolean false): ListDavEntry { 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: ListDavEntry) : UiState data class Error(val message: String) : UiState } class FileListViewModel(private val repo: DavRepository) : ViewModel() { private val _state MutableStateFlowUiState(UiState.Loading) val state: StateFlowUiState _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头。请求带上手动头之后服务器返回 401Authenticator被触发它拿不到正确凭据又重试一次形成循环。解决二选一。这个项目里选手动头因为账号密码是用户在设置页填的、存在本地不需要系统弹窗介入。构造 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 cleartextTrafficPermittedtrue domain includeSubdomainsfalse192.168.1.10/domain /domain-config /network-security-config再强调一次前面提过的优先级问题只要写了networkSecurityConfigusesCleartextTraffic就基本失效了所有规则以配置文件为准。很多人两个都改了一通还是连不上就是卡在这。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而是把「每次进目录都全量拉一遍」换成增量同步。做法也不复杂本地建一张表记录每个文件的路径、大小、修改时间和本地状态每次列目录时把服务器返回的ListDavEntry和本地记录做一次 diff只有变化的才动。同步状态我建议就用四个值够用且好判断状态含义触发条件SYNCED本地与服务器一致大小和修改时间都相同REMOTE_NEW服务器新增本地无记录远端有REMOTE_CHANGED服务器修改大小或修改时间不同LOCAL_ONLY仅本地存在本地有记录远端没有diff 的核心就是两次集合比对逻辑写在一个纯函数里方便单测fun diff(remote: ListDavEntry, local: ListSyncRecord): ListSyncAction { val localMap local.associateBy { it.path } val actions mutableListOfSyncAction() 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。顺序反过来一旦出问题你分不清是比对逻辑错了还是界面状态错了排查成本翻好几倍。希望这个思路帮到你。本文还有配套的精品资源点击获取