一个基于腾讯云 SMH 的 Windows 网盘客户端 Demo,从零到完全可用的全过程记录。
前言
你有没有想过,OneDrive 那种”点击即下载、云端图标状态”的体验是怎么实现的?
Windows 资源管理器中,OneDrive 文件左下角会显示一个蓝色小云朵(☁️),双击后自动下载并打开,下载完成后变成绿色对勾(✅)。右键还能选择”释放空间”把本地文件变回云朵。这套体验看起来很神奇,但核心其实是一个叫做 Cloud Files API (cfapi) 的 Windows 系统级 API。
本文分享了我用 C# 基于 Cloud Files API 和腾讯云智能媒资托管(SMH)从零搭建一个网盘客户端的完整过程,包括踩过的坑和最终方案。
项目是什么
SmhDemo 是一个 Windows 网盘客户端 Demo,实现了以下核心功能:
- 在 Windows 资源管理器中注册一个虚拟”SMH 网盘”同步盘
- 浏览云端文件目录,文件显示为”仅联机”(☁️ 云朵图标)
- 双击文件自动下载并打开,完成后变为”已在此设备上”(✅ 绿色勾)
- 支持”释放空间”脱水、”始终保留”固定等 OneDrive 同款操作
- 拖拽/复制文件到网盘自动上传,支持秒传检测
- 文件夹创建、删除、重命名等完整文件管理
- 双向增量同步(云端变更自动同步到本地)
- 冲突检测与解决
📁 此电脑
├── ☁️ SMH 网盘 ← 注册的同步根
│ ├── 📁 工作文档/
│ │ ├── ☁️ 报告.docx [占位符 - 云端]
│ │ ├── ✅ 方案.pdf [已下载 - 本地]
│ │ └── 📌 合同.pdf [已固定 - 不自动回收]
│ ├── 📁 图片/
│ └── ✅ 说明.txt
为什么选 SMH 作为后端
这个项目的核心目标是做一个类 OneDrive 的网盘客户端,而腾讯云智能媒资托管(Smart Media Hosting,SMH)正是为这类场景量身打造的 PaaS 产品。
SMH 是什么
SMH 定位于对象存储 COS 之上的存储处理中间件,专门为开发者构建网盘、相册、小程序等媒资应用提供一站式解决方案。相比直接使用 COS,SMH 在三个维度上做了关键增强:
| 能力维度 | COS(对象存储) | SMH(智能媒资托管) |
|---|---|---|
| 账号体系 | 需自行实现 | 内置 ToC 账号管理、多租户隔离 |
| 文件管理 | 扁平 Key-Value | 高效目录式文件管理、ETag 变更追踪 |
| 智能处理 | 需额外接入数据万象 | 内置 AI 分析(人脸、标签、元数据) |
SMH 的核心能力
存储与管理:文件数据托管在腾讯云 COS 上,享受多架构冗余、异地容灾、高持久性保障。SMH 在此基础上提供了目录式文件管理、文件级别读写权限控制、独立访问令牌鉴权,以及媒体库和租户空间的交叉授权体系。
文件操作 API:提供完整的 RESTful API,本项目用到的关键接口包括:
| API 能力 | 说明 | 本项目使用方式 |
|---|---|---|
| 目录列表 | 递归遍历云端目录树 | 用户浏览网盘文件夹时调用 |
| 文件下载 | 302 跳转到 COS URL,支持 Range 断点续传 | 水合引擎三阶段下载 |
| 简单上传 | ≤100MB 文件一步上传 | 小文件直接上传 |
| 分片上传 | >100MB 大文件分片传输 | 大文件上传 |
| 秒传检测 | 通过 SHA-256 Hash 匹配已有文件,命中即跳过上传 | 拖拽文件时自动探测 |
| 文件元数据操作 | 创建目录、删除、重命名 | 完整的文件管理功能 |
智能分析(AI 能力):SMH 集成了腾讯云的 AI 技术,虽然本项目是 Demo 未深入使用,但这是 SMH 区别于纯存储方案的核心价值:
- 人脸识别与聚类 — 自动识别图片中的人脸并聚类,适合智能相册场景
- 标签识别 — 对图片内容自动打标签,海量标签覆盖常见事物
- 元数据提取 — 提取图片/视频的拍摄时间、GPS 定位等信息
- 智能检索 — 基于标签、人脸等 AI 分析结果实现快速检索
为什么 SMH 适合做网盘后端
SMH 天然支持网盘场景所需的核心能力:秒传(避免重复传输)、分片上传(大文件可靠传输)、ETag 变更追踪(增量同步的基础)、目录式管理(资源管理器的文件树映射)。同时,它作为 PaaS 中间件,无需自行搭建存储服务、无需管理服务器,申请一个媒体库即可通过 API 完成所有操作。
本项目 Demo 阶段主要使用了 SMH 的存储与文件管理能力,后续完全可以基于 SMH 的 AI 能力扩展出智能相册、内容检索等高级功能。
技术选型
| 决策项 | 选择 | 理由 |
|---|---|---|
| 客户端语言 | C# / .NET 8 | Windows API 集成方便,P/Invoke 灵活 |
| 后端存储 | 腾讯云 SMH | 免搭建,API 齐全,秒传+分片+目录管理开箱即用 |
| 资源管理器集成 | Windows Cloud Files API (cfapi) | OneDrive 同款技术,原生占位符文件+按需下载 |
| 本地状态存储 | SQLite (WAL 模式) | 轻量可靠,线程安全 |
| 系统托盘 | WinForms NotifyIcon | 后台常驻,右键菜单管理 |
为什么选 Cloud Files API 而不是其他方案?
Windows 提供了几种与资源管理器集成的方式:
- Shell Namespace Extension (NSE):高度自定义但开发复杂度极高
- Sync Engine (cfapi):OneDrive/OneDrive for Business 使用的方案,原生支持占位符、按需下载、状态图标
- 虚拟文件系统驱动:需要内核签名,部署困难
cfapi 是唯一一个”系统内置 + 开发友好 + 体验和 OneDrive 一致”的方案。它是 Windows 10 1709 起内置的 API,微软官方称之为”Cloud Filter”。
整体架构
整个系统分为 6 层,从上到下依次为:

各层职责:
| 层 | 项目 | 职责 |
|---|---|---|
| Windows OS | 系统层 | Explorer 提供用户界面,cfapi 提供占位符/回调机制,NTFS 提供文件系统支撑 |
| SmhDemo.App | 应用层 | SyncHostedService 管理 6 步启动序列和生命周期,系统托盘提供状态展示和操作入口 |
| SmhDemo.CloudFiles | 核心层 | SyncProvider 分发 cfapi 回调,HydrationEngine 处理下载水合,FSW 监控本地变更,RemoteSyncService 轮询云端变更 |
| SmhDemo.SmhClient | HTTP 层 | SmhHttpClient 封装 SMH REST API(秒传/简单/分片三种上传模式),TokenManager 管理访问令牌 |
| SmhDemo.Storage | 持久层 | SyncStateStore 记录文件同步状态(路径/ETag/大小/InodeId),ScopeRuleStore 管理选择性同步规则 |
| Cloud Backend | 云端 | SMH API 处理元数据操作,COS 对象存储承载实际文件数据 |
数据流:
- 下载路径(蓝色):用户双击文件 → Explorer 触发 cfapi 回调 → HydrationEngine 三阶段流水线下载 → 4MB 分块传输给 cfapi → 标记 IN_SYNC
- 上传路径(绿色):FSW 检测本地变更 → 计算 SHA-256 hash → 转换为同步占位符 → 秒传探测/简单上传/分片上传
- 远程同步(橙色):RemoteSyncService 每 30 秒轮询 → ETag 对比 → 冲突检测 → 本地占位符创建/更新/删除
- 脱水(红色):用户右键”释放空间” → Windows 设置 UNPINNED 属性 → FSW 后台扫描检测 → 调用 CfDehydratePlaceholder 释放磁盘空间
核心机制详解
1. 占位符文件:一切的基础
cfapi 的核心概念是占位符文件 (Placeholder File)。它是一个真实存在于 NTFS 文件系统上的文件,但内容在云端,本地只有约 1KB 的元信息(文件名、大小、时间戳等)。在资源管理器中看起来和普通文件一样,只是左下角多了个云朵图标。
占位符文件有三种状态:
| 状态 | 图标 | 含义 | 磁盘占用 |
|---|---|---|---|
| 已脱水 | ☁️ 蓝色云朵 | 仅元信息,内容在云端 | ~1KB |
| 已水合 | ✅ 绿色勾(空心) | 内容已下载到本地 | 完整大小 |
| 已固定 | ✅ 绿色勾(实心) | 系统不会自动回收 | 完整大小 |
状态转换逻辑和 OneDrive 完全一致:
☁️ 已脱水 ──双击打开──→ ✅ 已水合 ──"始终保留"──→ ✅ 已固定
↑ │ │
└──────"释放空间"───────┴────────────────────────┘
2. 目录浏览:FETCH_PLACEHOLDERS 回调
当用户在资源管理器中展开同步根下的某个文件夹时,Windows 内核驱动 cldflt.sys 会拦截目录枚举请求,并向我们的程序发送 FETCH_PLACEHOLDERS 回调。
// 回调核心流程
void OnFetchPlaceholders(callbackInfo, callbackParameters)
{
// 1. 转换本地路径为 SMH 云端路径
var smhPath = ConvertToSmhPath(localPath);
// 2. 调用 SMH API 获取目录内容
var items = await smhClient.ListAllDirectoryItemsAsync(smhPath);
// 3. 用 CfCreatePlaceholders 批量创建占位符
placeholderManager.CreatePlaceholders(localPath, items);
}
关键踩坑:不要用 CfExecute(TransferPlaceholders) 的数组参数来创建占位符——会创建出”可见但不可访问”的坏占位符。必须用 CfCreatePlaceholders 独立创建,再用 CfExecute 通知完成。
防重入机制:Windows 可能对同一目录多次触发回调。用 ConcurrentDictionary 缓存已填充的目录路径,避免无限循环。
3. 文件下载:三阶段水合引擎
这是整个项目最核心的部分。当用户双击一个”已脱水”的文件时,cldflt.sys 检测到文件内容不可用,发送 FETCH_DATA 回调。
我设计了一个三阶段水合引擎,支持进度报告、断点续传和并发控制:
阶段1: 下载数据
├── 小文件(<50MB): 下载到 MemoryStream
└── 大文件(≥50MB): 下载到临时 FileStream
└── 支持 HTTP Range 断点续传
阶段2: 传输给 cfapi
├── 4MB 分块(必须 4096 页对齐!)
└── 每 4MB 或 500ms 报告进度(Explorer 显示进度条)
阶段3: 完成处理
├── CfSetInSyncState(IN_SYNC)
├── SHChangeNotify 刷新图标
└── 更新 SQLite 同步状态
页对齐这个坑值得单独说。CfExecute(TransferData) 要求 Offset 和 Length 必须是 4096(PAGE_SIZE)的倍数,最后一块除外。但 stream.ReadAsync() 返回的块大小不保证对齐。解决方案是用 4MB 累积缓冲区,攒满一整块再传输:
byte[] chunkBuffer = new byte[4 * 1024 * 1024]; // 4MB,本身已 4096 对齐
int chunkFilled = 0;
while ((bytesRead = await stream.ReadAsync(...)) > 0)
{
chunkFilled += bytesRead;
if (chunkFilled >= ChunkSize)
{
// 满块传输(已页对齐)
TransferData(connectionKey, transferKey, chunkBuffer, ChunkSize, transferred);
transferred += ChunkSize;
}
}
// 流结束时刷新余量(最后一块不要求页对齐)
if (chunkFilled > 0)
TransferData(..., chunkFilled, transferred);
4. 文件上传:先转后传 + 秒传
用户拖拽文件到同步文件夹时,FileSystemWatcher 检测到 Created 事件,触发上传流程。
上传流程采用三步走策略:
步骤 1: 计算哈希(在文件还是普通文件时,避免转占位符后数据变化)
SMH 自定义迭代式 SHA256(不是标准 SHA256)
步骤 2: 转换为同步占位符(先转后传,消除"无身份窗口")
步骤 3: 智能上传
→ 秒传探测(强制 HTTP/1.1)
→ 命中(200) → 直接完成
→ 未命中 → 按文件大小选择上传方式
→ ≤100MB: 简单上传
→ >100MB: 分片上传
秒传的 HTTP/1.1 陷阱:.NET 8 的 HttpClient 默认协商 HTTP/2,但 SMH 服务端在 HTTP/2 下无法正确解析秒传 POST body 中的 hash 参数,导致秒传永远不命中。诊断过程很曲折——curl 和 Python 都返回 200,唯独 .NET 返回 201。最终通过 request.Version = HttpVersion.Version11 解决。
另一个坑:扩展名大小写。SMH 秒传索引按小写扩展名匹配,.MP4 无法命中但 .mp4 可以。解决方案是探测时强制小写扩展名,命中后 rename 回原始文件名。
5. 释放空间(脱水):主动检测而非被动回调
这里有一个非常反直觉的设计:Windows 的”释放空间”操作不会触发 cfapi 的脱水回调!
它只是设置了一个 FILE_ATTRIBUTE_UNPINNED 属性标记,然后就没有然后了。提供程序必须自己主动调用 CfDehydratePlaceholder 来执行实际脱水。
所以我用 FileSystemWatcher 监控文件属性变化,检测到 Unpinned 标志后主动执行脱水:
用户右键"释放空间"
→ Windows 设置 Unpinned 属性
→ FSW 检测到 Attributes 变化
→ 检查:是占位符 + 已水合 + Unpinned
→ 主动调用 CfDehydratePlaceholder()
→ 文件变回 ☁️ 云朵状态,释放本地磁盘空间
脱水的前提条件是文件必须处于 IN_SYNC 状态。如果水合后忘记调用 CfSetInSyncState,脱水会返回 0x80070187 错误。
6. 双向增量同步
单向上传解决了,但如果在另一台设备上修改了云端文件呢?需要一个远程同步服务来检测云端变更:
RemoteSyncService 每 30 秒轮询
→ 递归遍历云端目录树
→ 对比本地 sync_state 中的 ETag
→ 检测变更类型:
├── REMOTE_NEW: 云端新增 → 本地创建占位符
├── REMOTE_MODIFIED: 云端修改 → 本地更新占位符
└── REMOTE_DELETED: 云端删除 → 保留已水合文件,仅删脱水占位符
智能间隔:基础 30 秒 → 无变更时逐步延长到 120 秒 → 检测到变更切换快速模式 15 秒 × 3 轮。
那些让人头秃的坑
坑 1:文件拷贝到网盘报 0x8007017C
用户拷贝文件到同步文件夹时,Windows 等待占位符转换完成,但转换需要重试(文件可能被 Indexer 锁定),超时后返回错误。
解决:将占位符转换改为后台异步执行,不阻塞 Windows 的复制操作。
坑 2:PPT 保存后文件变成普通文件
PowerPoint 的”安全保存”模式:先写临时文件 pptXXXX.tmp → 删除原文件 → 重命名 tmp 为原文件名。FSW 只看到一个 Renamed 事件,旧路径没有 sync_state,被跳过处理。
解决:通过模式匹配识别(旧路径是 .tmp + 新路径有 sync_state),视为内容更新触发上传。
坑 3:回收站恢复后文件过一会自动消失
这是两个独立 Bug 叠加:
- RemoteSync 的 SuppressEvents 缓冲回放模式会回放”RemoteSync 自己删除占位符”产生的 Deleted 事件,导致删了云端文件
- HandleRemoteDeletedAsync 无条件删除本地已水合文件
解决:SuppressEvents 改为 Drop 模式(不缓冲不回放),HandleRemoteDeletedAsync 保护已水合文件。
坑 4:下载大文件超时 0x800701AA
cfapi 的 FETCH_DATA 回调有 60 秒超时。大文件一次性下载整个流期间不调用任何 cfapi 函数,超时后报错。
解决:在下载循环中每 15 秒调用一次 CfReportProviderProgress,这个调用会重置超时计时器。
开发进度
整个项目经历了 24 个 Phase 的迭代,从最小可用(MVP)到完整的网盘客户端:
- Phase 1-2:最小可用 — 浏览云端文件 + 下载打开
- Phase 3-4:写操作 — 上传、创建、删除、重命名
- Phase 5-7:产品化 — 系统托盘、开机自启、看门狗保活
- Phase 8:脱水功能 — 释放空间 + FileIdentity 修复
- Phase 10:水合引擎重构 — 进度报告、断点续传、并发控制
- Phase 11-16:企业级特性 — 冲突检测、选择性同步、双向同步
- Phase 17-18:秒传修复 — HTTP/1.1 强制 + 扩展名归一化
- Phase 19-22:稳定性加固 — 剪切操作、回收站恢复、事件防丢失
累计编写了超过 660 个自动化测试用例,覆盖了水合引擎、秒传检测、冲突解决、选择性同步等核心模块。
总结
做一个”类 OneDrive”的网盘客户端,核心就是用好 Cloud Files API。这个 API 提供了系统级的集成能力,但文档和示例都比较少,开发过程中踩了不少坑。
最关键的几点经验:
- 占位符文件的页对齐传输 — 违反 4096 对齐要求会得到莫名其妙的错误码
- IN_SYNC 状态管理 — 水合后必须标记 IN_SYNC,脱水前必须 IN_SYNC
- FileIdentity 不可遗漏 — CfConvertToPlaceholder 必须传入,否则脱水直接报错
- “释放空间”需要主动脱水 — Windows 不会自动触发,必须自己检测 Unpinned 属性
- FSW 事件不要缓冲回放 — 无法区分事件来源,回放可能导致数据丢失
- HTTP 版本很关键 — .NET 默认 HTTP/2,某些服务端(如 SMH)在 HTTP/2 下行为不同
项目代码结构和完整的 3000 行设计文档都维护在仓库中,如果你也在做类似的 Windows 云存储客户端集成,希望这篇文章能帮你少走弯路。