简介:这是一份面向 Android 开发者的 WebDAV 客户端项目源码包,围绕移动端访问支持 WebDAV 协议的远程文件服务器展开,适合需要实现远程目录浏览、文件上传下载、重命名、移动与删除等功能的开发者参考学习。压缩包共 149 个文件,整体约 512KB,包含 Kotlin 与 Java 源码、XML 布局、PNG 界面素材、Gradle 构建脚本以及少量 Dart 文件,覆盖界面呈现、网络请求、身份认证和构建配置等模块。已有约 1790 人学习下载,说明其在 WebDAV 移动端实践方向具有一定参考价值。读者可从中了解基于 HTTP 扩展协议的远程文件管理思路,查看网络库封装、认证流程、异步任务与权限适配等实现,并借助现有目录结构梳理 Android 客户端从界面到数据访问的分层方式,为课程设计或小型工具开发提供对照,也可作为移动端远程存储功能的学习样例。
1. 一个 Android 端 WebDAV 客户端,卡点从来不在协议本身
手里有台自建存储、或者公司内网挂着一台文件服务器,想在手机上像用网盘一样浏览、上传、下载,最直接的路子就是 WebDAV。它本质是 HTTP/1.1 的一组扩展方法,服务端几乎都支持,但 Android 系统层没有原生客户端,这意味着从请求封装、XML 解析、目录树渲染到断点续传,全得自己搭。这份 WebDAVClient 源码包就是干这件事的:用 Kotlin 把 PROPFIND、PUT、GET 这些方法包成可调用的客户端,再拼出一个能跑的文件浏览界面。适合两类人——想给私有存储做个轻量入口的开发者,以及想拿一个真实 HTTP 扩展协议练手移动端网络层的人。协议规范读起来不难,难的是移动网络下的超时、认证和 XML 命名空间这些落地细节。
2. WebDAV 请求层:六类方法在 Kotlin 里怎么包才不散
WebDAV 的方法比普通 REST 多,OPTIONS、PROPFIND、PROPPATCH、MKCOL、COPY、MOVE、LOCK、UNLOCK 各有各的语义。如果每个方法都手写一遍OkHttpClient.newCall(request).execute(),代码会迅速膨胀成意大利面。这个客户端的做法是先抽一个统一的请求执行器,把认证头、超时、异常映射收口到一处,再让每个方法只负责拼 request body 和解析响应。这一层的设计直接决定了后面目录树和上传下载写起来顺不顺。
2.1 为什么用 OkHttp 而不是 HttpURLConnection
HttpURLConnection 在 Android 上能用,但它在连接池复用、HTTP/2 支持、拦截器扩展上明显吃亏。WebDAV 一个目录列表请求经常伴随多个子资源探测,连接复用带来的延迟收益很实在。OkHttp 的Interceptor还能把 Basic 认证头、User-Agent、日志这些横切逻辑统一挂上去,不用每个请求都手动加。常见做法是建一个单例OkHttpClient,通过newBuilder()派生不同超时配置的实例,而不是到处 new。
object DavHttpClient { // 基础客户端:连接池和拦截器共享,避免每个请求重建 private val baseClient = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .connectionPool(ConnectionPool(8, 5, TimeUnit.MINUTES)) .addInterceptor(AuthInterceptor()) // 统一注入认证头 .addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }) .build() // 上传/下载单独放宽读写超时,避免大文件被掐断 val transferClient: OkHttpClient = baseClient.newBuilder() .readTimeout(0, TimeUnit.SECONDS) .writeTimeout(0, TimeUnit.SECONDS) .build() val browseClient: OkHttpClient = baseClient }这段代码里三个参数值得说清楚。connectTimeout设 15 秒是因为移动网络下 TCP 握手偶尔会卡在 10 秒以上,设太短会误杀弱网请求;readTimeout浏览请求给 30 秒,足够服务端遍历大目录;但上传下载走的transferClient把读写超时设成 0,意思是无限等待,因为一个几百 MB 的文件在慢速网络上传输几分钟很正常,固定超时会导致传到一半连接被客户端主动断掉。连接池的maxIdleConnections=8和keepAliveDuration=5 分钟是按目录浏览场景估的,一次列表请求通常并发 1~3 个子请求,8 个空闲连接足够复用。
认证拦截器里要注意,Basic 认证是把用户名:密码做 Base64,这不是加密,明文可逆,所以必须走 HTTPS。拦截器只对同域请求加头,避免把凭证泄漏到第三方 CDN。
class AuthInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val original = chain.request() val credential = CredentialStore.current() ?: return chain.proceed(original) // 仅对配置的 WebDAV 主机注入认证,防止凭证外泄 if (original.url.host != credential.host) return chain.proceed(original) val token = Base64.encodeToString( "${credential.user}:${credential.password}".toByteArray(), Base64.NO_WRAP ) val authed = original.newBuilder() .header("Authorization", "Basic $token") .build() return chain.proceed(authed) } }CredentialStore是我习惯的叫法,实际实现可以放 EncryptedSharedPreferences,不要明文存密码。Base64.NO_WRAP是为了避免 Android 的 Base64 默认每 76 字符换行,有些服务端对换行敏感会返回 401。
2.2 六类方法的封装与状态码映射
WebDAV 的正常状态码不止 200。PROPFIND 成功返回 207 Multi-Status,MKCOL 成功返回 201,DELETE 成功返回 204。如果客户端只认 200,会把大量正常响应判成失败。封装时用一个DavResponse把 code、body、headers 一起带出来,再由上层决定怎么解读。
data class DavResponse( val code: Int, val body: String?, val headers: Headers ) { val isSuccess: Boolean get() = code in 200..299 || code == 207 } suspend fun execute( method: String, url: HttpUrl, body: RequestBody? = null, headers: Map<String, String> = emptyMap() ): DavResponse = withContext(Dispatchers.IO) { val builder = Request.Builder().url(url).method(method, body) headers.forEach { (k, v) -> builder.header(k, v) } DavHttpClient.browseClient.newCall(builder.build()).execute().use { resp -> DavResponse(resp.code, resp.body?.string(), resp.headers) } }几个方法的具体拼法:
- OPTIONS:探测服务端支持哪些方法,响应头
Allow里会列出PROPFIND, PUT, DELETE等。启动时调一次,能提前判断服务端是否禁用了某个方法。 - PROPFIND:请求体是 XML,
Depth: 1拿当前目录及直接子项,Depth: 0只拿当前资源自身属性。这是目录浏览的核心。 - MKCOL:创建集合(目录),body 传 null,成功是 201。
- PUT:上传文件,body 是文件流,成功是 201(新建)或 204(覆盖)。
- DELETE:删除资源,成功是 204,目录非空时可能返回 207 并在 body 里列出失败项。
- MOVE/COPY:移动和复制,靠
Destination头指定目标 URL,注意目标必须是完整绝对 URL。
状态码映射里有个容易忽略的点:207 的 body 是 XML,里面每个response节点有自己的status,可能整体 207 但部分子项失败。封装层只负责把 207 当成功放行,具体逐项成败留给解析层判断。这一步做对了,后面目录树才不会把“部分失败”渲染成全空。
3. 目录树展示:PROPFIND 的 Depth、命名空间和 XML 解析
目录浏览是用户感知最强的部分,也是最容易踩 XML 命名空间坑的地方。WebDAV 的响应体里,D:response、D:propstat、D:href这些前缀不是固定的,有的服务端用d:,有的用D:,有的干脆用默认命名空间不带前缀。用正则或者字符串截取去抽 href,换个服务端就翻车。正确姿势是用支持命名空间的 XML 解析器。
3.1 PROPFIND 请求体与 Depth 参数
一个最小可用的 PROPFIND 请求体长这样:
<?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>resourcetype用来区分文件和目录:目录的resourcetype里会包含<d:collection/>,文件则为空。getcontentlength对目录通常不返回或返回 0,所以判断类型不能靠 size。getlastmodified是 RFC 1123 格式的日期字符串,解析时要按EEE, dd MMM yyyy HH:mm:ss zzz的 locale 处理,注意用Locale.US,否则某些机型上月份缩写解析失败。
Depth 参数的选择直接影响请求量和响应体大小。Depth: 0只返回请求 URI 自身的属性,适合刷新单个文件元信息;Depth: 1返回当前集合的直接子项,是目录列表的默认选择;Depth: infinity会递归整棵子树,服务端出于性能考虑经常直接返回 403 或 507,不要在生产客户端里默认用它。
fun buildPropfindBody(): RequestBody { val xml = """ <?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() return xml.toRequestBody("application/xml; charset=utf-8".toMediaType()) } suspend fun listDirectory(url: HttpUrl): List<DavEntry> { val resp = execute( method = "PROPFIND", url = url, body = buildPropfindBody(), headers = mapOf("Depth" to "1") ) if (!resp.isSuccess) throw DavException(resp.code, resp.body) return DavXmlParser.parseMultiStatus(resp.body ?: "") }Depth: 1的响应里,第一个response节点通常是请求的目录自身,后面才是子项。解析后要把自身过滤掉,否则目录树里会出现自己套自己。toMediaType()里的 charset 要写 utf-8,虽然 XML 声明里也有,但 HTTP 头缺了某些严格服务端会返回 415。
3.2 用 XmlPullParser 把多状态响应抽成文件树
Android 自带的XmlPullParser基于事件流,内存占用低,适合解析可能上万节点的目录列表。关键是要处理命名空间:用parser.namespace和parser.name组合判断,而不是只看parser.name,因为前者带命名空间 URI,后者只有本地名。
object DavXmlParser { fun parseMultiStatus(xml: String): List<DavEntry> { val parser = Xml.newPullParser() parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) parser.setInput(xml.reader()) val entries = mutableListOf<DavEntry>() var href: String? = null var isCollection = false var length = 0L var modified: String? = null var inResponse = false var event = parser.eventType while (event != XmlPullParser.END_DOCUMENT) { when (event) { XmlPullParser.START_TAG -> when { parser.namespace == "DAV:" && parser.name == "response" -> { inResponse = true href = null; isCollection = false; length = 0L; modified = null } inResponse && parser.namespace == "DAV:" && parser.name == "href" -> { href = parser.nextText().trim() } inResponse && parser.namespace == "DAV:" && parser.name == "collection" -> { isCollection = true } inResponse && parser.namespace == "DAV:" && parser.name == "getcontentlength" -> { length = parser.nextText().trim().toLongOrNull() ?: 0L } inResponse && parser.namespace == "DAV:" && parser.name == "getlastmodified" -> { modified = parser.nextText().trim() } } XmlPullParser.END_TAG -> { if (parser.namespace == "DAV:" && parser.name == "response") { href?.let { entries += DavEntry( href = it, isDirectory = isCollection, size = length, lastModified = modified ) } inResponse = false } } } event = parser.next() } // 去掉首项自身 return entries.drop(1) } }这段解析里有三个逻辑点。第一,FEATURE_PROCESS_NAMESPACES必须开,否则parser.namespace拿不到值,所有DAV:判断都会失败。第二,href是 URL 编码过的路径,比如中文目录会变成%E4%B8%AD%E6%96%87,渲染前要用URLDecoder.decode(path, "UTF-8")还原,但注意不要对整个 URL 解码,只对 path 部分解码,否则+号和%2F会出错。第三,drop(1)依赖服务端把自身放在第一个,绝大多数实现如此,但稳妥做法是按请求 URL 的 path 精确比对过滤,而不是盲删第一项。
还有一个边界:getcontentlength对目录不返回时,上面代码里length保持 0,这是对的,但 UI 上不要把目录显示成“0 B”,应该按isDirectory显示“文件夹”。resourcetype为空的自闭合节点也要正确处理,XmlPullParser 会把<d:resourcetype/>解析成 START_TAG 紧跟 END_TAG,不会触发collection的 START_TAG,所以isCollection保持 false,文件判断就成立了。
4. 上传下载与断点续传:OkHttp 流式 Body 和 Range 头怎么配合
WebDAV 的 PUT 和 GET 语义和普通 HTTP 一样,但移动端要额外考虑进度回调、取消、断点续传。OkHttp 的RequestBody默认是一次性写流,上传大文件时不方便暂停;下载端如果不带 Range,某次网络抖动就得从头再拉。这一层的设计目标是:上传能报进度、能取消,下载能续传、能校验。
4.1 PUT 上传的流式 RequestBody 与进度回调
OkHttp 的RequestBody.writeTo()是在 IO 线程里被调用的,进度回调从这里往外抛最直接。常见做法是继承RequestBody,重写contentType()、contentLength()和writeTo(),在writeTo()里按块读文件并累计已写字节。
class ProgressRequestBody( private val file: File, private val contentType: MediaType? = "application/octet-stream".toMediaType(), private val onProgress: (sent: Long, total: Long) -> Unit ) : RequestBody() { override fun contentType() = contentType override fun contentLength() = file.length() override fun writeTo(sink: BufferedSink) { val total = file.length() var sent = 0L file.source().use { source -> val buffer = Buffer() while (true) { val read = source.read(buffer, 8 * 1024L) // 8KB 一块,平衡回调频率 if (read == -1L) break sink.write(buffer, read) sent += read onProgress(sent, total) // 注意:此回调在 IO 线程 } } } } suspend fun upload(url: HttpUrl, file: File) = withContext(Dispatchers.IO) { val body = ProgressRequestBody(file) { sent, total -> // 通过 LiveData / StateFlow 抛到主线程更新 UI UploadBus.publish(sent, total) } val request = Request.Builder() .url(url) .put(body) .header("Content-Type", "application/octet-stream") .build() DavHttpClient.transferClient.newCall(request).execute().use { resp -> if (!resp.isSuccessful) throw DavException(resp.code, resp.body?.string()) } }块大小选 8KB 是个经验值。太小回调太频繁,主线程更新 UI 会掉帧;太大进度条跳变明显。回调里绝对不能直接碰 View,要经 StateFlow 或 LiveData 切主线程。取消上传靠协程的Job.cancel(),底层 OkHttp 的Call.cancel()会中断 socket 写,writeTo里会抛IOException,捕获后按“用户取消”处理,不要弹成错误 toast。
注意一点:PUT 是覆盖语义。同名文件会被直接替换,没有版本。如果目标是新建,可以先用 PROPFINDDepth: 0探测目标 URL 是否存在,存在则提示用户或改用带时间戳的文件名。这个探测多一次往返,但对防止误覆盖很值。
4.2 GET 下载的 Range 头与断点续传
断点续传的前提是服务端支持Accept-Ranges: bytes。第一次下载前可以发 HEAD 或带Range: bytes=0-0的 GET 探测,响应里Content-Range: bytes 0-0/总长度能拿到文件总大小。下载时维护一个已写字节数,下次续传从Range: bytes=已写-开始,期望返回 206 Partial Content。
suspend fun download( url: HttpUrl, target: File, resumeFrom: Long = if (target.exists()) target.length() else 0L ) = withContext(Dispatchers.IO) { val request = Request.Builder() .url(url) .header("Range", "bytes=$resumeFrom-") .build() DavHttpClient.transferClient.newCall(request).execute().use { resp -> // 服务端不支持 Range 会返回 200 全量,此时必须覆盖重写 val append = resp.code == 206 val total = resp.header("Content-Range") ?.substringAfter("/")?.toLongOrNull() ?: resp.body!!.contentLength() resp.body!!.byteStream().use { input -> FileOutputStream(target, append).use { output -> val buf = ByteArray(16 * 1024) var written = resumeFrom while (true) { val n = input.read(buf) if (n == -1) break output.write(buf, 0, n) written += n DownloadBus.publish(written, total) } } } } }这段代码的关键判断是resp.code == 206。如果服务端忽略 Range 返回 200,响应体是完整文件,此时append必须是 false,否则会把全量数据追加到已有半截文件后面,得到一个损坏文件。FileOutputStream的第二个参数append就是控制这个的。另一个细节是Content-Range解析,格式是bytes 0-1023/4096,取/后面那段才是总大小,substringAfter("/")正好。
续传的落地还要配合本地记录:下载任务开始时把url、target path、已写长度存到数据库或 SharedPreferences,进程被杀后下次启动能恢复。只靠target.length()判断已写长度在并发下载同名文件时会算错,所以任务 ID 和文件路径要一一绑定。
提示:断点续传完成后建议做一次长度校验,
target.length()应等于总大小,不等则删掉重下。校验成本极低,能挡住绝大多数半截文件被当成完整文件打开的问题。
5. 避坑与排查:同步静默失败的五个现场
这一章全是血泪经验。WebDAV 客户端最难受的不是报错,是“看起来成功了但数据不对”。下面五个现场都按现象、原因、解决来拆,遇到同类问题可以直接对号入座。
5.1 目录列表为空但服务端明明有文件
现象:PROPFIND 返回 207,解析出来entries是空的,日志里 body 有内容。
原因:XmlPullParser没开命名空间处理,parser.namespace恒为 null,所有DAV:判断失效;或者服务端返回的前缀不是D:,代码里写死了字符串匹配。
解决:确认setFeature(FEATURE_PROCESS_NAMESPACES, true)已调用,所有判断用parser.namespace == "DAV:" && parser.name == "xxx",不要用parser.name == "D:href"。改完后用抓包工具把原始 XML 贴出来逐节点对一遍,十分钟能定位。
5.2 上传成功但文件大小是 0
现象:PUT 返回 201,服务端上文件存在但大小为 0。
原因:ProgressRequestBody重写了contentLength()返回文件长度,但writeTo()里读流时source.read(buffer, 8192)的返回值处理错了,比如把-1也当成有效长度写进去,或者flush没做导致缓冲没落盘。
解决:在writeTo()末尾用sink.flush()显式刷一次;单测里用一个 3 字节的小文件跑一遍,断言服务端收到的长度等于 3。OkHttp 对contentLength和实际写入字节数不一致会抛ProtocolException,如果没抛但大小为 0,多半是服务端侧的问题,抓包看Content-Length头是否发出去了。
5.3 中文文件名上传后变成乱码或 404
现象:本地文件叫报告.pdf,上传后在服务端看到%E6%8A%A5%E5%91%8A.pdf,或者直接 404。
原因:URL 拼接时对 path 段做了两次编码,或者该编码的地方没编码。OkHttp 的HttpUrl.Builder.addPathSegment()会自动做百分号编码,如果传进去的已经是编码后的字符串,就会二次编码变成%25E6...。
解决:拼 URL 时传原始中文给addPathSegment(),让它自己编码;解析服务端返回的href时,用URLDecoder.decode(href, "UTF-8")解码后再展示。这两个方向相反,搞混就出乱码。判断方法很简单:抓包看请求行里的 path,如果是%25开头就是二次编码。
5.4 自签证书服务端连不上,报 Trust anchor not found
现象:内网自建服务端用自签证书,App 请求直接抛SSLHandshakeException。
原因:Android 7.0 以后默认不信任用户安装的 CA,自签证书不在系统信任链里。
解决:正规做法是把服务端证书的 CA 打包进network_security_config.xml并配置trust-anchors,只对该域名生效。不要图省事写一个信任所有证书的TrustManager,那等于把 HTTPS 降级成 HTTP,凭证和文件全裸奔。配置后要验证:用openssl s_client -connect host:port看证书链是否完整,缺中间证书也会导致握手失败。
5.5 下载到 99% 卡住最后不动
现象:进度条停在 99%,日志没有异常,等几分钟后超时。
原因:服务端返回的Content-Length比实际 body 大,或者用了 chunked 编码但结束块没发。客户端input.read()一直阻塞等剩余字节。
解决:transferClient的readTimeout不能设 0 无限等,应设一个较大的有限值,比如 120 秒,靠超时兜底而不是永久阻塞。同时在循环里判断written >= total时主动 break,不要只依赖read == -1。这两个改完,99% 卡死会变成明确超时异常,用户能重试。
6. 进阶:用 WorkManager 编排增量同步,以及怎么验证它真的增量了
文件浏览和手动上传下载是基础,真正省心的是后台增量同步:指定本地目录和服务端目录,按修改时间只传变化的文件。Android 上做这件事的合规姿势是 WorkManager,配合ExistingPeriodicWorkPolicy.KEEP避免重复入队。约束条件设NetworkType.UNMETERED可以避免用户流量被偷跑,设requiresBatteryNotLow防止低电量时传大文件。
class DavSyncWorker( appContext: Context, params: WorkerParameters ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { val localDir = File(inputData.getString("localDir") ?: return Result.failure()) val remoteDir = inputData.getString("remoteDir") ?: return Result.failure() val since = inputData.getLong("lastSyncAt", 0L) return try { // 只取本地比上次同步时间新的文件,避免全量扫描 val changed = localDir.walkTopDown() .filter { it.isFile && it.lastModified() > since } .toList() for (file in changed) { val target = "$remoteDir/${file.name}" upload(target.toHttpUrl(), file) // 复用第 4 章的上传逻辑 } Result.success(workDataOf("syncedCount" to changed.size)) } catch (e: CancellationException) { throw e } catch (e: Exception) { if (runAttemptCount < 3) Result.retry() else Result.failure() } } }这段编排里有三个参数决定增量是否真的“增量”。lastSyncAt应该在上次同步成功后写入,用服务端返回的getlastmodified或本地系统时间都行,但必须只增不减;walkTopDown()的过滤条件用lastModified() > since而不是全量遍历后比对哈希,后者在几千文件时启动延迟明显。Result.retry()的退避由 WorkManager 自动处理,默认指数退避,最多重试 3 次后放弃,避免死循环耗电。
验证增量有没有生效,我一般两步走。第一步看 Worker 返回的syncedCount,第二次同步同一目录时它应该是 0,如果不为 0 说明时间戳比较写反了或者lastSyncAt没持久化。第二步抓包看 PUT 请求数量,只应有变动文件的请求。曾经有一次syncedCount一直是全量,排查半天发现是lastModified()返回毫秒而since存的是秒,比较时永远成立,这种量纲不一致在跨端同步里是高频翻车点。从那以后我每次写时间比较都强制在字段名上标单位,lastSyncAtMs、fileModifiedMs,名字里带单位,代码 review 时一眼能看出来。希望这套拆法帮到你,少走几个我踩过的坑。
本文还有配套的精品资源,点击获取