简介:一份基于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,网络层选了 OkHttp,XML 解析用 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 返回的 XML |
data/WebDavFile.kt | 文件模型,包含文件名、路径、大小、修改时间、是否为目录 |
ui/FileListFragment.kt | 文件列表页,维护路径栈,发起加载和下载 |
util/FileNameEncoder.kt | URL 编码工具,处理中文和空格 |
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): List<WebDavFile> = withContext(Dispatchers.IO) { val url = buildUrl(path) val request = Request.Builder() .url(url) .method("PROPFIND", RequestBody.create( MediaType.parse("application/xml; charset=utf-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): List<WebDavFile> { val parser = XmlPullParserFactory.newInstance().newPullParser() parser.setInput(input, "UTF-8") val files = mutableListOf<WebDavFile>() 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: bytes=xxx-,然后判断服务器返回的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: Array<out X509Certificate>?, authType: String?) {} override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) { if (!chain?.any { it.equals(cert) }!!) { throw CertificateException("Server cert not trusted") } } override fun getAcceptedIssuers(): Array<X509Certificate> = 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: bytes=xxx-并且最终文件能打开,说明追加逻辑生效;如果文件损坏,检查是不是写入模式用成了覆盖写而不是追加。
验证完断点续传,接着想加什么就按你实际场景来了:想要下载队列,在仓库层包一个Channel顺序消费;想要多线程分块下载,那要自己跑进度合并;想要 WebDAV 的锁能力(避免多人同时编辑一个文件),在协议层加LOCK和UNLOCK请求,但注意很多服务器对锁的支持是按扩展实现的,需要先探测。
这套代码我持续维护了一段时间,最深的感触是:WebDAV 客户端写起来不难,难的是服务器兼容性——每个服务器厂商都有自己的一套小脾气,不是Depth头不对就是日期格式不一样。从那以后我每次接一个新的 WebDAV 服务端,都会先写一个冒烟测试脚本,把列目录、下载、断点续传、中文路径四条路径全部走一遍,再开始写业务代码,省掉了后面至少一个礼拜的联调扯皮时间。希望帮到你。
本文还有配套的精品资源,点击获取