一、项目起源:一个桌面工具的需求演变
三年前为市场团队开发了一个TikTok视频下载的WPF工具,用于收集竞品营销素材。随着团队扩张到Mac用户,以及远程办公需求的增加,这个工具经历了WPF → Blazor Hybrid → Blazor Server的三代架构演进。
本文记录技术选型决策、各方案的优缺点,以及为什么最终采用了"轻客户端+云服务"的混合架构。
二、第一代:WPF桌面客户端
2.1 技术选型理由
开发效率:团队熟悉C和XAML,无需学习成本
硬件访问:需要本地文件系统操作和FFmpeg调用
离线能力:市场人员经常出差,需要无网环境使用

2.2 核心架构设计
采用MVVM模式 + Prism框架:
TikTokDownloader.WPF/
├── Views/
│ ├── MainWindow.xaml
│ ├── DownloadQueueView.xaml
│ └── SettingsView.xaml
├── ViewModels/
│ ├── MainWindowViewModel.cs
│ └── DownloadQueueViewModel.cs
├── Services/
│ ├── TikTokParserService.cs
│ ├── DownloadService.cs
│ └── FFmpegService.cs
└── Models/
└── VideoItem.cs
2.3 关键实现代码
下载服务(基于HttpClient):
public class DownloadService : IDownloadService
{
private readonly HttpClient _httpClient;
private readonly IProgressReporter _progressReporter;
public async Task<DownloadResult> DownloadVideoAsync(
string videoUrl,
string outputPath,
CancellationToken cancellationToken = default)
{
// 创建临时文件
var tempPath = Path.Combine(
Path.GetTempPath(),
$"{Guid.NewGuid()}.mp4");
try
{
using var response = await _httpClient.GetAsync(
videoUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength ?? 1L;
var canReportProgress = totalBytes != 1;
await using var contentStream = await response.Content.ReadAsStreamAsync();
await using var fileStream = new FileStream(
tempPath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
81920, // 80KB缓冲区
FileOptions.Asynchronous);
var buffer = new byte[81920];
long totalRead = 0;
int read;
while ((read = await contentStream.ReadAsync(
buffer, 0, buffer.Length, cancellationToken)) > 0)
{
await fileStream.WriteAsync(buffer, 0, read, cancellationToken);
totalRead += read;
if (canReportProgress)
{
var percentage = (double)totalRead / totalBytes 100;
_progressReporter.Report(percentage);
}
}
// 移动临时文件到目标位置
File.Move(tempPath, outputPath, true);
return new DownloadResult
{
Success = true,
FilePath = outputPath,
FileSize = totalRead,
DownloadedAt = DateTime.UtcNow
};
}
catch (Exception ex)
{
// 清理临时文件
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
return new DownloadResult
{
Success = false,
ErrorMessage = ex.Message
};
}
}
}
FFmpeg集成(无水印处理):
public class FFmpegService : IFFmpegService
{
private readonly string _ffmpegPath;
public async Task<string> RemoveWatermarkAsync(
string inputPath,
string outputPath)
{
// TikTok水印位置:左上角和右下角
// 使用delogo滤镜去除
var arguments = $"i \"{inputPath}\" " +
$"vf \"delogo=x=10:y=10:w=200:h=50," + // 左上角
$"delogo=x=w210:y=h60:w=200:h=50\" " + // 右下角
$"c:a copy \"{outputPath}\"";
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = _ffmpegPath,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
{
var error = await process.StandardError.ReadToEndAsync();
throw new InvalidOperationException($"FFmpeg失败: {error}");
}
return outputPath;
}
}
2.4 WPF方案的局限
跨平台问题:市场团队使用MacBook,WPF无法支持。
部署成本:每次TikTok接口变更,需要重新打包分发MSI安装包。
维护困境:TikTok的反爬策略频繁更新,客户端内置的解析逻辑平均每月失效。
三、第二代:Blazor Hybrid(.NET MAUI Blazor)
3.1 技术选型
为了支持Windows和macOS,尝试使用.NET MAUI Blazor:
UI层:Blazor WebAssembly(共享代码)
原生层:MAUI处理文件系统和进程调用
目标平台:Windows 10+、macOS 12+
3.2 架构调整
// MAUI原生服务(平台特定实现)
public interface IPlatformService
{
Task<string> PickSaveLocationAsync(string defaultFileName);
Task LaunchFileAsync(string filePath);
string GetFFmpegPath();
}
// Windows实现
if WINDOWS
public class WindowsPlatformService : IPlatformService
{
public async Task<string> PickSaveLocationAsync(string defaultFileName)
{
var savePicker = new FileSavePicker();
savePicker.SuggestedStartLocation = PickerLocationId.Downloads;
savePicker.SuggestedFileName = defaultFileName;
savePicker.FileTypeChoices.Add("MP4视频", new List<string> { ".mp4" });
// WinUI3特定API调用...
return await savePicker.PickSaveFileAsync();
}
// 其他实现...
}
endif
// macOS实现
if MACCATALYST
public class MacPlatformService : IPlatformService
{
// 使用AppKit的NSSavePanel...
}
endif
3.3 遇到的坑
FFmpeg分发:MAUI的单一项目无法优雅处理跨平台原生依赖,需要分别为Windows和macOS打包不同的FFmpeg二进制文件,应用体积暴增到200MB+。
性能问题:Blazor WebView在macOS上内存占用高,长时间下载后UI卡顿。
TikTok解析:仍然面临签名算法更新的问题,客户端逻辑需要频繁热更新。
四、第三代:Blazor Server + 云服务
4.1 架构重构思路
既然解析逻辑必须放在服务端(便于更新),不如将重度计算也外包:
[Blazor Server Web App]
↓
[API Gateway] → [TikTok Parse Service] → [Third Party Downloader]
↓
[SignalR实时通知]
↓
[User Browser]
优势:
零客户端维护:解析逻辑在服务端,更新无需重新部署
跨平台:任何支持浏览器的设备均可使用
无FFmpeg依赖:视频处理在云端完成
4.2 Blazor Server实现
实时进度推送:
@page "/download"
@inject IDownloadService DownloadService
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable
<h3>TikTok视频下载</h3>
<div class="inputgroup mb3">
<input @bind="videoUrl" class="formcontrol"
placeholder="粘贴TikTok链接..." />
<button @onclick="StartDownload" class="btn btnprimary"
disabled="@isDownloading">
@(isDownloading ? "下载中..." : "开始下载")
</button>
</div>
@if (downloadProgress > 0)
{
<div class="progress mb3">
<div class="progressbar" role="progressbar"
style="width: @downloadProgress%">
@downloadProgress%
</div>
</div>
}
@if (!string.IsNullOrEmpty(resultMessage))
{
<div class="alert @(downloadSuccess ? "alertsuccess" : "alertdanger")">
@resultMessage
@if (downloadSuccess)
{
<a href="@downloadLink" target="_blank" class="alertlink">
点击下载
</a>
}
</div>
}
@code {
private string videoUrl;
private int downloadProgress;
private bool isDownloading;
private bool downloadSuccess;
private string resultMessage;
private string downloadLink;
private HubConnection? hubConnection;
protected override async Task OnInitializedAsync()
{
// 建立SignalR连接,接收实时进度
hubConnection = new HubConnectionBuilder()
.WithUrl(Navigation.ToAbsoluteUri("/downloadhub"))
.Build();
hubConnection.On<int>("ReceiveProgress", (progress) =>
{
downloadProgress = progress;
InvokeAsync(StateHasChanged);
});
await hubConnection.StartAsync();
}
private async Task StartDownload()
{
if (string.IsNullOrWhiteSpace(videoUrl))
return;
isDownloading = true;
downloadProgress = 0;
resultMessage = null;
StateHasChanged();
try
{
var result = await DownloadService.ProcessAsync(
videoUrl,
hubConnection.ConnectionId);
downloadSuccess = result.Success;
resultMessage = result.Success ? "下载完成!" : $"失败: {result.Error}";
downloadLink = result.DownloadUrl;
}
catch (Exception ex)
{
downloadSuccess = false;
resultMessage = $"错误: {ex.Message}";
}
finally
{
isDownloading = false;
StateHasChanged();
}
}
public async ValueTask DisposeAsync()
{
if (hubConnection is not null)
{
await hubConnection.DisposeAsync();
}
}
}
后端Hub服务:
public class DownloadHub : Hub
{
public async Task ReportProgress(string connectionId, int percentage)
{
await Clients.Client(connectionId)
.SendAsync("ReceiveProgress", percentage);
}
}
public class DownloadService : IDownloadService
{
private readonly IThirdPartyDownloader _downloader;
private readonly IHubContext<DownloadHub> _hubContext;
public async Task<DownloadResult> ProcessAsync(
string tiktokUrl,
string connectionId)
{
// 调用第三方服务解析和下载
var task = await _downloader.CreateTaskAsync(tiktokUrl);
// 轮询进度并通过SignalR推送
while (!task.IsCompleted)
{
await Task.Delay(500);
task = await _downloader.GetStatusAsync(task.Id);
if (task.Progress > 0)
{
await _hubContext.Clients.Client(connectionId)
.SendAsync("ReceiveProgress", task.Progress);
}
}
return new DownloadResult
{
Success = task.Success,
DownloadUrl = task.FileUrl,
Error = task.ErrorMessage
};
}
}
4.3 第三方服务集成
评估的在线工具(https://twittervideodownloaderx.com/tiktok_downloader_cn)技术特点:
API设计:
public class ThirdPartyDownloader : IThirdPartyDownloader
{
private readonly HttpClient _client;
private readonly string _apiKey;
public ThirdPartyDownloader(IConfiguration config)
{
_apiKey = config["TikTokDownloader:ApiKey"];
_client = new HttpClient
{
BaseAddress = new Uri("https://twittervideodownloaderx.com/api/v1/")
};
_client.DefaultRequestHeaders.Add("XAPIKey", _apiKey);
}
public async Task<DownloadTask> CreateTaskAsync(string url)
{
var response = await _client.PostAsJsonAsync("tiktok/parse", new
{
url = url,
format = "mp4",
no_watermark = true,
webhook = "https://myapp.com/webhook" // 异步回调
});
return await response.Content
.ReadFromJsonAsync<DownloadTask>();
}
public async Task<DownloadTask> GetStatusAsync(string taskId)
{
var response = await _client.GetAsync($"tiktok/status/{taskId}");
return await response.Content
.ReadFromJsonAsync<DownloadTask>();
}
}
技术优势:
无水印处理:服务端完成,无需本地FFmpeg
格式支持:MP4/WebM/音频,自动转码
CDN加速:下载链接通过全球CDN分发
Webhook回调:支持异步通知,减少轮询开销
五、三代架构对比
| 维度 | WPF | MAUI Blazor | Blazor Server + 云服务 |
| 开发成本 | 低 | 中 | 低 |
| 跨平台 | 仅Windows | Win/Mac | 全平台(浏览器) |
| 部署维护 | 高(需分发安装包) | 高(应用商店审核) | 低(热更新) |
| 解析成功率 | 70% | 70% | 95%+ |
| 无水印支持 | 需本地FFmpeg | 需本地FFmpeg | 云端自动处理 |
| 离线能力 | 完全支持 | 部分支持 | 不支持 |
| 适合场景 | 纯内网环境 | 跨平台桌面 | 联网办公 |
六、最终架构决策
当前方案:Blazor Server + 第三方下载服务
保留WPF版本:仅用于无网环境,功能受限(有水印、解析成功率低)
技术债务管理:
将TikTok解析逻辑完全外包,团队专注业务功能
使用Feature Flag控制功能开关,便于降级
七、对.NET开发者的建议
-
桌面应用选型
纯Windows:WPF仍是最佳选择,生态成熟
跨平台:考虑Avalonia UI(比MAUI更稳定)
快速交付:Blazor Server + PWA模式 -
SignalR实时通信
使用IHubContext从服务层推送消息
注意连接断开重连和消息去重 -
第三方服务集成
封装SDK层,便于切换供应商
实现熔断降级,防止单点故障
八、结语
从WPF到Blazor的演进,本质是从"拥有技术"到"使用服务"的转变。当TikTok的防护成本超过自建收益时,聪明的工程师会选择站在巨人的肩膀上。
最终架构让团队从繁琐的签名破解中解放,专注于用户体验和业务价值创造。这才是技术选型的终极目标。
浙公网安备 33010602011771号