说实话,FTP 这个协议老得像古董,但在工业控制、内网文件同步这些场景里,它活得比你想象中滋润多了。前段时间我用 WinForms + C# 写了一个完整的 FTP 客户端,从连接管理、异步目录浏览,到带断点续传的传输队列,把能踩的坑基本都踩了一遍。今天把核心设计思路和几个关键实现细节掰开揉碎说给你听。
很多人写 WinForms 项目,最后 FrmMain.cs 膨胀到几千行,UI 逻辑、业务逻辑、数据访问全搅在一起。这玩意儿后期维护起来,真的是一种折磨。
这个项目我拆成了三层:
Core 层:FTPClient(协议通信)、ConnectionManager(连接历史管理)、FileTransfer(传输队列调度)Models 层:纯数据模型,FTPConnection、TransferTask、FileItem 等Forms 层:只负责 UI 呈现和用户交互,不碰业务逻辑FrmMain 的构造函数里,三个核心对象各司其职:
csharp_ftpClient = new FTPClient();
_connectionManager = new ConnectionManager();
_fileTransfer = new FileTransfer(_ftpClient);
FileTransfer 依赖注入 FTPClient,这样测试和替换都方便。简单。干净。



ConnectionManager 干的事情其实很朴素:维护一个最多 20 条的连接历史列表,每次连接成功就把这条记录顶到最前面,重复的自动去重。
csharpusing 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.com 和 ftp.example.com 就会存两条重复记录,体验很差。
另外,LastLocalPath 和 LastRemotePath 也持久化了。每次打开软件,自动恢复上次浏览的目录——这个细节很多工具都没做,但用起来真的舒服。
LoadRemoteDirectoryAsync 是整个 UI 交互里最容易翻车的地方。网络 I/O 不做异步,界面就会卡死,用户体验直接崩。
csharpprivate 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,必须统一处理:
csharpprivate 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。所以统一走这个小包装:
csharpprivate void SafeUI(Action action)
{
if (InvokeRequired)
{
BeginInvoke(action);
return;
}
action();
}
用 BeginInvoke 而不是 Invoke,避免后台线程被 UI 线程阻塞导致死锁。这个坑很多人踩过,记一下。
FileTransfer 是整个项目里最复杂的一块。它内部维护一个 Queue<TransferTask> 加一个 SemaphoreSlim 信号量,后台有一个长跑的 ProcessQueueAsync 协程在等待任务。
csharpprivate 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 更灵活。
断点续传怎么搞? 下载任务入队时,先检查本地文件是否已存在:
csharppublic 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 块知道这次取消是"暂停"而不是"真取消":
csharpcatch (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 默认长相确实有点上世纪感。不过用纯代码设置颜色,效果其实还行。项目里用了一套蓝色系配色:
csharpvar 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 = true 和 HideSelection = false,选中状态在失焦后也保持高亮——这个细节很多人忘了设,结果用户点完就不知道选的是哪条了。
远程目录树不能一次全部加载,服务器目录可能很深。这里用了经典的"占位节点"懒加载方案:每个目录节点初始只放一个文本为 "..." 的假子节点,展开时再真正请求:
csharpprivate 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 存完整路径字符串,省去了反向查找路径的麻烦。
代码里有一处值得注意的小问题,ConnectAsync 里对 Server 的空判断写了两遍:
csharpif (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 许可协议。转载请注明出处!