编辑
2026-03-18
C#
00

目录

🏗️ 先聊架构——别把所有逻辑塞进 Form 里
👨‍💻先看效果
🔌 连接管理——历史记录怎么做才不烦人
⚡ 异步目录加载——UI 线程的命根子
📦 传输队列——断点续传的正确姿势
🎨 主题样式——WinForms 也能好看
🌲 远程目录树的懒加载
🐛 顺手记一个 Bug
写在最后
WinForms FTP 异步编程 断点续传`

说实话,FTP 这个协议老得像古董,但在工业控制、内网文件同步这些场景里,它活得比你想象中滋润多了。前段时间我用 WinForms + C# 写了一个完整的 FTP 客户端,从连接管理、异步目录浏览,到带断点续传的传输队列,把能踩的坑基本都踩了一遍。今天把核心设计思路和几个关键实现细节掰开揉碎说给你听。


🏗️ 先聊架构——别把所有逻辑塞进 Form 里

很多人写 WinForms 项目,最后 FrmMain.cs 膨胀到几千行,UI 逻辑、业务逻辑、数据访问全搅在一起。这玩意儿后期维护起来,真的是一种折磨。

这个项目我拆成了三层:

  • CoreFTPClient(协议通信)、ConnectionManager(连接历史管理)、FileTransfer(传输队列调度)
  • Models:纯数据模型,FTPConnectionTransferTaskFileItem
  • Forms:只负责 UI 呈现和用户交互,不碰业务逻辑

FrmMain 的构造函数里,三个核心对象各司其职:

csharp
_ftpClient = new FTPClient(); _connectionManager = new ConnectionManager(); _fileTransfer = new FileTransfer(_ftpClient);

FileTransfer 依赖注入 FTPClient,这样测试和替换都方便。简单。干净。


👨‍💻先看效果

image.png

image.png

image.png


🔌 连接管理——历史记录怎么做才不烦人

ConnectionManager 干的事情其实很朴素:维护一个最多 20 条的连接历史列表,每次连接成功就把这条记录顶到最前面,重复的自动去重。

csharp
using AppFTPClient.Models; using AppFTPClient.Utils; namespace AppFTPClient.Core; public class ConnectionManager { private readonly AppConfig _config; public ConnectionManager() { _config = Config.Load(); } public IReadOnlyList<FTPConnection> ConnectionHistory => _config.ConnectionHistory; public FTPConnection? CurrentConnection { get; private set; } public void SetCurrent(FTPConnection connection) { CurrentConnection = connection; SaveConnection(connection); } public void ClearCurrent() { CurrentConnection = null; } public FTPConnection? GetMostRecent() { return _config.ConnectionHistory.FirstOrDefault(); } public void SaveConnection(FTPConnection connection) { var existing = _config.ConnectionHistory.FirstOrDefault(x => string.Equals(x.Server, connection.Server, StringComparison.OrdinalIgnoreCase) && x.Port == connection.Port && string.Equals(x.Username, connection.Username, StringComparison.OrdinalIgnoreCase)); if (existing is not null) { _config.ConnectionHistory.Remove(existing); } _config.ConnectionHistory.Insert(0, connection); if (_config.ConnectionHistory.Count > 20) { _config.ConnectionHistory.RemoveRange(20, _config.ConnectionHistory.Count - 20); } Config.Save(_config); } public string LastLocalPath { get => _config.LastLocalPath; set { _config.LastLocalPath = value; Config.Save(_config); } } public string LastRemotePath { get => _config.LastRemotePath; set { _config.LastRemotePath = value; Config.Save(_config); } } }

去重判断用的是 Server + Port + Username 三元组,注意用了 OrdinalIgnoreCase——FTP 服务器地址大小写不敏感,不加这个比较,用户手打 FTP.Example.comftp.example.com 就会存两条重复记录,体验很差。

另外,LastLocalPathLastRemotePath 也持久化了。每次打开软件,自动恢复上次浏览的目录——这个细节很多工具都没做,但用起来真的舒服。


⚡ 异步目录加载——UI 线程的命根子

LoadRemoteDirectoryAsync 是整个 UI 交互里最容易翻车的地方。网络 I/O 不做异步,界面就会卡死,用户体验直接崩。

csharp
private async Task LoadRemoteDirectoryAsync(string path) { if (!_ftpClient.IsConnected) return; try { var items = await _ftpClient.GetDirectoryListingAsync(path); _currentRemotePath = NormalizeRemotePath(path); // ... 更新 UI } catch (Exception ex) { UpdateStatus($"远程目录读取失败: {ex.Message}"); Logger.Error("加载远程目录失败", ex); } }

有几个细节值得说:

路径规范化不能省。 远程路径拼接是个高频操作,用户可能手打 //data/files,也可能从树节点拼出 \backup,必须统一处理:

csharp
private static string NormalizeRemotePath(string path) { if (string.IsNullOrWhiteSpace(path)) return "/"; var normalized = path.Replace('\\', '/'); if (!normalized.StartsWith('/')) normalized = "/" + normalized; while (normalized.Contains("//")) normalized = normalized.Replace("//", "/"); return normalized; }

这个函数调用频率极高,写得稍微啰嗦一点没关系,但一定要健壮。

SafeUI 封装必须有。 传输进度回调是从后台线程触发的,直接操作控件会抛 InvalidOperationException。所以统一走这个小包装:

csharp
private void SafeUI(Action action) { if (InvokeRequired) { BeginInvoke(action); return; } action(); }

BeginInvoke 而不是 Invoke,避免后台线程被 UI 线程阻塞导致死锁。这个坑很多人踩过,记一下。


📦 传输队列——断点续传的正确姿势

FileTransfer 是整个项目里最复杂的一块。它内部维护一个 Queue<TransferTask> 加一个 SemaphoreSlim 信号量,后台有一个长跑的 ProcessQueueAsync 协程在等待任务。

csharp
private readonly Queue<TransferTask> _queue = new(); private readonly SemaphoreSlim _signal = new(0); private void Enqueue(TransferTask task) { lock (_syncRoot) { _queue.Enqueue(task); _taskMap[task.Id] = task; } _signal.Release(); // 通知消费者有新任务 TaskUpdated?.Invoke(this, task); }

每入队一个任务就 Release() 一次信号量,消费端 WaitAsync() 阻塞等待,这是经典的生产者-消费者模式。比轮询省资源,比 BlockingCollection 更灵活。

断点续传怎么搞? 下载任务入队时,先检查本地文件是否已存在:

csharp
public TransferTask EnqueueDownload(string remotePath, string localPath) { var resumeOffset = 0L; if (File.Exists(localPath)) { resumeOffset = new FileInfo(localPath).Length; } var task = new TransferTask { Type = TransferType.Download, SourcePath = remotePath, DestinationPath = localPath, ResumeOffsetBytes = resumeOffset, Message = resumeOffset > 0 ? "断点续传" : string.Empty }; // ... }

ResumeOffsetBytes 记录已下载字节数,传给 FTPClient.DownloadFileAsync 时作为 REST 偏移量。FTP 协议本身支持 REST 命令,这是断点续传的底层基础。

暂停和恢复的状态机是这里最烧脑的部分。暂停分两种情况——任务还在队列里没开始执行,直接从队列移除即可;任务正在执行中,就得通过 CancellationTokenSource 取消,同时在 _pausedTaskIds 里打个标记,让 catch 块知道这次取消是"暂停"而不是"真取消":

csharp
catch (OperationCanceledException) { if (_pausedTaskIds.TryRemove(transferTask.Id, out _)) { transferTask.Status = TransferStatus.Paused; transferTask.Message = "已暂停"; // 记录当前已下载字节数,供恢复时使用 if (transferTask.Type == TransferType.Download && File.Exists(transferTask.DestinationPath)) { transferTask.ResumeOffsetBytes = new FileInfo(transferTask.DestinationPath).Length; } } else { transferTask.Status = TransferStatus.Canceled; transferTask.Message = "已取消"; } }

这个"用标记位区分取消原因"的技巧,在异步任务管理里非常实用,记住它。


🎨 主题样式——WinForms 也能好看

WinForms 默认长相确实有点上世纪感。不过用纯代码设置颜色,效果其实还行。项目里用了一套蓝色系配色:

csharp
var bgMain = Color.FromArgb(245, 248, 255); // 主背景,接近白色的淡蓝 var bgPanel = Color.FromArgb(232, 240, 255); // 面板背景 var accent = Color.FromArgb(37, 99, 235); // 强调色,深蓝 StyleButton(btnConnect, accent, Color.FromArgb(29, 78, 216), Color.White);

按钮用 FlatStyle.Flat 配合自定义边框色,去掉那个难看的默认立体感。ListView 加上 FullRowSelect = trueHideSelection = false,选中状态在失焦后也保持高亮——这个细节很多人忘了设,结果用户点完就不知道选的是哪条了。


🌲 远程目录树的懒加载

远程目录树不能一次全部加载,服务器目录可能很深。这里用了经典的"占位节点"懒加载方案:每个目录节点初始只放一个文本为 "..." 的假子节点,展开时再真正请求:

csharp
private async void tvRemote_BeforeExpand(object sender, TreeViewCancelEventArgs e) { if (e.Node.Nodes.Count == 1 && e.Node.Nodes[0].Text == "...") { e.Node.Nodes.Clear(); var items = await _ftpClient.GetDirectoryListingAsync(path); foreach (var dir in items.Where(x => x.IsDirectory && x.Name is not "." and not "..")) { var node = new TreeNode(dir.Name) { Tag = dir.FullPath }; node.Nodes.Add("..."); // 继续放占位节点 e.Node.Nodes.Add(node); } } }

这个模式在文件树、组织架构树等场景里极为通用。节点的 Tag 存完整路径字符串,省去了反向查找路径的麻烦。


🐛 顺手记一个 Bug

代码里有一处值得注意的小问题,ConnectAsync 里对 Server 的空判断写了两遍:

csharp
if (string.IsNullOrWhiteSpace(connection.Server)) { MessageBox.Show("请输入FTP服务器地址", ...); return; } // 下面紧接着又判断了一次,完全多余 if (string.IsNullOrWhiteSpace(connection.Server)) { MessageBox.Show("请输入FTP服务器地址", ...); return; }

这种重复判断不影响功能,但说明这段代码可能是复制粘贴时没清理干净。实际项目里,连接前的校验应该统一放在 BuildConnectionFromInputs 之后、ConnectAsync 之前做,逻辑更清晰。


写在最后

整个项目下来,给我印象最深的反而不是某个具体的技术点,而是职责边界的划分FrmMain 只管展示,FileTransfer 只管调度,ConnectionManager 只管持久化——每个类都有且只有一件事要做,改起来心里有底。

WinForms 老了,但这些设计思路一点都不老。下次不管你写什么桌面工具,先把层次捋清楚,后面省心很多。


#C# #WinForms #FTP #异步编程 #断点续传

相关信息

通过网盘分享的文件:AppFTPClient.zip 链接: https://pan.baidu.com/s/1zxl9Heb53iZzA4uNAnCgGA?pwd=pjw4 提取码: pjw4 --来自百度网盘超级会员v9的分享

本文作者:技术老小子

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!