一、项目起源:一个桌面工具的需求演变

三年前为市场团队开发了一个TikTok视频下载的WPF工具,用于收集竞品营销素材。随着团队扩张到Mac用户,以及远程办公需求的增加,这个工具经历了WPF → Blazor Hybrid → Blazor Server的三代架构演进。

本文记录技术选型决策、各方案的优缺点,以及为什么最终采用了"轻客户端+云服务"的混合架构。

二、第一代:WPF桌面客户端

2.1 技术选型理由

开发效率:团队熟悉C和XAML,无需学习成本
硬件访问:需要本地文件系统操作和FFmpeg调用
离线能力:市场人员经常出差,需要无网环境使用

生成推广图片 18

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开发者的建议

  1. 桌面应用选型
    纯Windows:WPF仍是最佳选择,生态成熟
    跨平台:考虑Avalonia UI(比MAUI更稳定)
    快速交付:Blazor Server + PWA模式

  2. SignalR实时通信
    使用IHubContext从服务层推送消息
    注意连接断开重连和消息去重

  3. 第三方服务集成
    封装SDK层,便于切换供应商
    实现熔断降级,防止单点故障

八、结语

从WPF到Blazor的演进,本质是从"拥有技术"到"使用服务"的转变。当TikTok的防护成本超过自建收益时,聪明的工程师会选择站在巨人的肩膀上。

最终架构让团队从繁琐的签名破解中解放,专注于用户体验和业务价值创造。这才是技术选型的终极目标。

posted on 2026-02-13 18:46  yqqwe  阅读(1)  评论(0)    收藏  举报