用Kotlin实现Android WebDAV客户端:协议封装、PROPFIND解析与文件管理实战
简介一份基于Kotlin开发的Android WebDAV客户端完整工程面向需要对接WebDAV协议或学习Android网络编程的开发者。资源包内含149个文件约512KB主要包含Java/Kotlin源码、XML布局与配置文件、PNG图标资源以及Gradle构建脚本另有少量Dart文件与元数据便于理解混合技术栈下的项目组织方式。目前已有1788人学习下载。通过学习该资源可系统掌握WebDAV协议的核心请求方法PROPFIND、COPY、MOVE等、Basic/Digest认证流程、文件上传下载与异步任务处理以及如何在Android中借助OkHttp/Retrofit等网络库封装自定义协议。项目结构清晰源码细节完整适合希望提升网络编程能力或快速搭建远程文件管理应用的Android开发者参考。1. WebDAVClient 是什么一个 Kotlin 写的 WebDAV 协议实现做安卓客户端开发的人应该都有过这种需求想直接在手机上管理远程服务器上的文件又不想走 FTP——移动网络下 FTP 的明文传输和被动模式实在折磨人。当时我接到一个内部工具需求要给运营同事做一个能连公司网盘、像文件管理器一样浏览下载文件的 App翻了一圈代码仓库最后盯上了这个用 Kotlin 写的 WebDAVClient。它做的事情很纯粹把 WebDAV 协议封装成安卓端能用的客户端支持列目录、下载、上传、删除、建目录这些基础操作UI 走的是文件管理器的路子。适合两类人一是想直接把 WebDAV 能力塞进自己 App 里的开发者二是拿它当协议层参考、想搞懂 PROPFIND 和 XML 解析怎么落地的人。2. 项目架构与目录结构一个文件管理器该有的样子2.1 技术选型与模块分层先说技术栈。客户端是纯 Kotlin网络层选了 OkHttpXML 解析用 Android 自带的 XmlPullParser异步用的协程——这套组合在安卓生态里是最不需要犹豫的配置。Kotlin 写网络请求比 Java 舒服太多尤其是数据类的序列化和空安全处理 WebDAV 那套多状态响应的时候能少写一堆样板代码。模块分层是典型的单 App 结构但分层思路清楚网络层封装 OkHttp 的调用负责构造 PROPFIND、MKCOL、DELETE 这些 WebDAV 方法解析层把服务器返回的 XML 转成文件模型仓库层把网络和解析的结果组装成 UI 能直接用的数据UI 层就是 RecyclerView 列表加一个路径栈维护当前目录。这套分层的价值在于你想换 UI 或者加缓存不需要动协议层你想换成别的协议比如 S3只需要替换网络层和解析层。2.2 目录结构与代码导览项目是常规的 Android 单模块工程主代码集中在app/src/main/java下按包名划分职责包/文件职责data/WebDavClient.kt封装 OkHttp 请求暴露listFiles、download、upload等方法data/WebDavParser.kt解析 PROPFIND 返回的 XMLdata/WebDavFile.kt文件模型包含文件名、路径、大小、修改时间、是否为目录ui/FileListFragment.kt文件列表页维护路径栈发起加载和下载util/FileNameEncoder.ktURL 编码工具处理中文和空格MainActivity.kt入口注入服务器地址和账号信息拿到代码先别急着跑建议从WebDavFile.kt读起几十行数据类把字段看明白后面解析层和 UI 层都好懂。这个数据类就是整个 App 的核心模型data class WebDavFile( val name: String, // 文件名不含路径 val displayName: String, // 用于 UI 展示的名字 val path: String, // 完整远程路径 val isDirectory: Boolean, // 是否为目录决定 UI 是展示为文件夹还是文件 val size: Long, // 文件大小字节 val lastModified: String // 服务器返回的修改时间字符串 )后面所有模块都在给这个模型喂数据或消费数据。isDirectory在 WebDAV 里不是直接给的要从 XML 属性里判断这就是解析层要做的事。协议层我单独说一句WebDAV 本质上是 HTTP 的扩展方法是 PROPFIND、MKCOL、COPY 这些请求头用Depth控制递归深度。这个客户端把协议方法都放在WebDavClient.kt里用 OkHttp 的Request.Builder直接指定方法名代码量不大但思路值得学习——不要引入额外的 WebDAV 库HTTP 层搞定的事情没必要再加依赖。3. 核心模块拆解PROPFIND 协议层与下载落盘3.1 网络层用 OkHttp 构造 PROPFIND 请求WebDAV 列目录靠的是 PROPFIND 方法请求体里带要查询的属性响应是一个 XML 文档。这个客户端的listFiles做了两件事构造请求、把响应交给解析层。suspend fun listFiles(path: String): ListWebDavFile withContext(Dispatchers.IO) { val url buildUrl(path) val request Request.Builder() .url(url) .method(PROPFIND, RequestBody.create( MediaType.parse(application/xml; charsetutf-8), propfindBody )) .addHeader(Depth, 1) // 只列当前目录不递归 .build() val response client.newCall(request).execute() if (!response.isSuccessful) { throw IOException(PROPFIND failed: ${response.code}) } response.body?.use { body - WebDavParser.parseMultistatus(body.byteStream()) } ?: emptyList() }几个参数说明一下。Depth: 1是说只返回当前目录这一层Depth: 0只返回自身Depth: infinity返回整棵子树——生产环境一定不要用 infinity目录深一点就把服务器打挂了。propfindBody是一个常量 XML 字符串声明要查哪些属性常见写法是请求displayname、getcontentlength、getlastmodified这几个属性。response.body?.use这个写法我提醒一句关键点必须把 body 包在use里显式关闭。响应体不关闭OkHttp 的连接池就没法复用跑久了 socket 耗尽App 表现为「用一段时间后所有请求都卡住」。这是 WebDAV 客户端特别容易踩的坑因为列目录操作频繁每次都泄漏一个连接一天下来必现。3.2 解析层XML 响应解析成文件列表PROPFIND 的响应长这样外层是multistatus里面一个个response节点每个response里有一个href表示路径和一个propstat包着属性。用 XmlPullParser 解析干净利落fun parseMultistatus(input: InputStream): ListWebDavFile { val parser XmlPullParserFactory.newInstance().newPullParser() parser.setInput(input, UTF-8) val files mutableListOfWebDavFile() var currentPath var currentName var currentSize 0L var isDirectory false var insideResponse false var eventType parser.eventType while (eventType ! XmlPullParser.END_DOCUMENT) { when (eventType) { XmlPullParser.START_TAG - when (parser.name) { response - { insideResponse true currentPath ; currentName ; currentSize 0L; isDirectory false } href - { if (insideResponse) { val raw parser.nextText() currentPath java.net.URLDecoder.decode(raw, UTF-8) } } getcontentlength - currentSize parser.nextText().toLongOrNull() ?: 0L collection - isDirectory true } XmlPullParser.END_TAG - if (parser.name response) { if (currentPath.isNotEmpty() currentPath ! /) { files.add(WebDavFile( name currentName.ifEmpty { currentPath.substringAfterLast(/) }, displayName currentName, path currentPath, isDirectory isDirectory, size currentSize, lastModified )) } insideResponse false } } eventType parser.next() } return files }逻辑说明collection标签是判断目录的关键——WebDAV 服务器用collection/这个空节点标记一个资源是集合目录而不是普通文件。href是完整路径但很多服务器返回的是 URL 编码过的所以先解码再存。currentPath.substringAfterLast(/)是为了从/a/b/c.txt里取出c.txt避免依赖服务器单独返回displayname——有些服务器不按标准返回但href一定有。这个解析器有个可以加强的地方lastModified字段我留空了因为完整解析getlastmodified需要处理 HTTP 日期格式RFC 1123不同服务器格式可能有差异。你在二次开发时补上就好注意java.text.SimpleDateFormat解析时要把 Locale 设为Locale.US否则在中文系统上解析英文月份名会直接崩。3.3 下载层响应流写进本地文件下载从 WebDAV 的角度看就是一次普通的 HTTP GET但这个客户端做了一件事值得说用协程把网络请求和文件写入放在 IO 调度器UI 不卡同时把进度回调上抛。suspend fun download(remotePath: String, localFile: File): Long withContext(Dispatchers.IO) { val request Request.Builder() .url(buildUrl(remotePath)) .get() .build() val response client.newCall(request).execute() if (!response.isSuccessful) { throw IOException(Download failed: ${response.code}) } val totalBytes response.body?.contentLength() ?: 0L var written 0L response.body?.use { body - body.byteStream().use { input - localFile.outputStream().use { output - val buffer ByteArray(DEFAULT_BUFFER_SIZE) while (true) { val read input.read(buffer) if (read -1) break output.write(buffer, 0, read) written read } } } } written }参数说明response.body?.contentLength()是服务器给的 Content-Length它有两个用途——给用户显示总进度以及在下载完成后校验实际写入字节数是否一致。不一致基本就是网络中断或者服务器提前关闭了连接这种场景下你应该把已写入的半成品文件删掉而不是保留一个看起来存在但没法打开的文件。注意这里用了body.byteStream()这是 OkHttp 的原始输入流。如果你要加断点续传得在请求头里带上Range: bytesxxx-然后判断服务器返回的contentLength()是否等于剩余大小——有些服务器不支持 Range会直接返回 200 而不是 206这时候得把文件从头重新写。这个小逻辑我在第 5 章展开。4. 常见问题排查WebDAV 客户端的五个坑4.1 Depth 头不生效列目录只返回一层现象调用listFiles时服务器有时候返回的确实是当前层但换了一台服务器就只返回一个空的multistatus或者干脆把整个目录树都返回了。原因这个和客户端关系不大是服务器对Depth头支持不一致。部分服务器尤其是基于某些老一代 Java 实现的只认Depth: 0和Depth: infinity遇到Depth: 1直接忽略或按 0 处理。解决列目录统一用Depth: 1但在初始化客户端时加一个服务器能力探测——先发一次Depth: 0的 PROPFIND 请求看返回的response数量是否正常如果异常就改用Depth: 1并把子级信息合并。或者更省事只在代码里留一个开关部署新服务器时手工切换。4.2 中文文件名和空格变成乱码现象服务器上明明叫财务报表 2024.xlsx客户端列出来是%E8%B4%A2%E5%8A%A1%202024.xlsx或者下载提示 404。原因href里返回的路径是 URL 编码的解析层用URLDecoder.decode解码后空格会被解码成但 WebDAV 路径里的空格只认%20另外有些服务器连 GET 请求也要你做 URL 编码。解决解码时要替换掉private fun decodePath(raw: String): String { return java.net.URLDecoder.decode(raw, UTF-8).replace(, ) }重新编码时反过来用URLEncoder.encode之后要把替换成%20。这个细节不处理中文系统上基本 100% 翻车。从那以后我每次写 WebDAV 工具类都会先写一对 encode/decode 的单元测试放空格、放中文、放特殊符号各来一遍。4.3 Android 10 分区存储写入失败现象App 在 Android 9 上下载文件正常到 Android 10 上写入公共目录报EACCES或FileNotFoundException。原因分区存储Scoped Storage在 Android 10 是强制行为App 不能直接往公共存储目录任意写文件只能写自己包名下的目录或通过 MediaStore 写媒体文件。解决这个客户端面向的是文件管理场景合理做法是下载目录选context.getExternalFilesDir(null)或者getExternalCacheDir()——这两个路径不需要额外权限。不要去申请MANAGE_EXTERNAL_STORAGE那个权限在应用商店审核时很容易被拒。如果你一定要写公共 Download 目录就走 MediaStore 的Downloadscollection但那样拿不到真实文件路径后续操作都会绕一层。4.4 自签名 HTTPS 证书握手失败现象连公司内网 WebDAV 服务器日志报SSLHandshakeException或CertPathValidatorException。原因内网服务器普遍用自签名证书安卓系统默认不信任。解决提供一个证书信任的配置入口不要在客户端里写死信任所有证书——这是安全底线。做法是把服务器的证书打包进raw资源用CertificateFactory加载后构建自定义TrustManager再传给 OkHttp。代码写在初始化那里两件事读证书文件、构造 OkHttpClient。private fun trustCertificate(context: Context, certResId: Int): OkHttpClient { val cf CertificateFactory.getInstance(X.509) val cert context.resources.openRawResource(certResId).use { input - cf.generateCertificate(input) } val trustManager object : X509TrustManager { override fun checkClientTrusted(chain: Arrayout X509Certificate?, authType: String?) {} override fun checkServerTrusted(chain: Arrayout X509Certificate?, authType: String?) { if (!chain?.any { it.equals(cert) }!!) { throw CertificateException(Server cert not trusted) } } override fun getAcceptedIssuers(): ArrayX509Certificate arrayOf(cert as X509Certificate) } return OkHttpClient.Builder() .sslSocketFactory(SSLContext.getInstance(TLS).apply { init(null, arrayOf(trustManager), null) }.socketFactory, trustManager) .build() }注意这个方案只在调试环境验证用。真上了生产最好走正规 CA 证书省掉这一整块。4.5 响应体未关闭导致连接池泄漏现象App 用半小时后所有网络请求都不返回日志没有任何报错重启 App 恢复正常。原因这就是我在 3.1 节说的响应体没关闭。OkHttp 的连接池有上限默认 5 个连接每个没关闭的连接都被占着新请求排队等到超时。解决检查所有execute()之后有没有response.body?.close()。用 Kotlin 的话就是保证 body 一定在use块里。这类问题不会立刻暴露要跑一段时间才出现排查起来特别费劲——我当时是靠抓取 OkHttp 的Event日志看到连接一直被占用才锁定了原因。5. 跑起来与二次开发从改地址到断点续传5.1 跑起来三个配置点第一件事是确认本机环境Android Studio 版本不用太新能跑 Gradle 同步即可SDK 建议装 API 29 以上因为代码里用了协程Kotlin 版本跟项目走不要手动升。导入项目时用Open选择根目录的build.gradle让 Gradle 自己拉依赖首次同步慢是正常的。代码层面只需要改三个地方。第一处是MainActivity.kt里的服务器地址常量——把https://example.com/dav/换成你自己的 WebDAV 服务地址注意必须以/结尾。第二处是账号密码代码里默认是通过Basic Auth加在请求头里在WebDavClient的初始化处有一个Interceptor改成你的用户名和密码。第三处是下载目录在FileListFragment里找到下载的地方默认是context.getExternalFilesDir(null)如果需要指定别的位置就改这里。把 App 装到模拟器或真机上先确认你能访问服务器——如果连不上先拿电脑上的curl -X PROPFIND --user user:pass -H Depth: 1 你的地址验证服务器端是通的这是最快缩小问题范围的办法。5.2 第一个改造给下载加断点续传这个客户端现在下载是全量拉取网络抖动一次整个文件重来。我建议你上手做的第一件事是加 Range 支持改动不大但收益明显。核心是下载前先看本地文件的大小然后带 Range 头重新请求suspend fun downloadResumable(remotePath: String, localFile: File): Long withContext(Dispatchers.IO) { val existingSize if (localFile.exists()) localFile.length() else 0L val requestBuilder Request.Builder().url(buildUrl(remotePath)) if (existingSize 0L) { requestBuilder.addHeader(Range, bytes$existingSize-) } val request requestBuilder.get().build() val response client.newCall(request).execute() when { response.code 206 - { // 服务器支持断点续传从 existingSize 开始追加 response.body?.use { body - localFile.outputStream(/* append */ true).use { output - body.byteStream().copyTo(output) } } } response.code 200 - { // 服务器不支持 Range全量重新下载 response.body?.use { body - localFile.outputStream().use { output - body.byteStream().copyTo(output) } } } else - throw IOException(Download failed: ${response.code}) } localFile.length() }逻辑说明这里必须处理 206 和 200 两种状态。206 表示服务器接受了 Range 请求返回的只是剩余部分200 表示服务器忽略 Range 直接返回完整内容此时就覆盖写。还有人会犯一个错误文件在本地写到一半明明没下载完就发 Range 请求但本地这个文件可能是别的任务写的——所以调用方要保证文件路径和下载任务是一一对应的关系。5.3 改造后的验证方法断点续传改造完验证方式可以用一个笨但可靠的办法用一个比较大的文件几十 MB把下载速度限制到很慢或者在copyTo之前加一个Thread.sleep(10)模拟卡顿下载到一半强杀 App。重新启动 App 后再点下载如果日志显示发出了Range: bytesxxx-并且最终文件能打开说明追加逻辑生效如果文件损坏检查是不是写入模式用成了覆盖写而不是追加。验证完断点续传接着想加什么就按你实际场景来了想要下载队列在仓库层包一个Channel顺序消费想要多线程分块下载那要自己跑进度合并想要 WebDAV 的锁能力避免多人同时编辑一个文件在协议层加LOCK和UNLOCK请求但注意很多服务器对锁的支持是按扩展实现的需要先探测。这套代码我持续维护了一段时间最深的感触是WebDAV 客户端写起来不难难的是服务器兼容性——每个服务器厂商都有自己的一套小脾气不是Depth头不对就是日期格式不一样。从那以后我每次接一个新的 WebDAV 服务端都会先写一个冒烟测试脚本把列目录、下载、断点续传、中文路径四条路径全部走一遍再开始写业务代码省掉了后面至少一个礼拜的联调扯皮时间。希望帮到你。本文还有配套的精品资源点击获取