第一部分:核心概念大升级 (三大法宝)

在看代码之前,我们要先理解这节课引入的三个“超强武器”:

  1. 单例模式 (static FTPMgr instance)

    • 概念:整个游戏里,负责 FTP 管理的“大管家”只需要一个。无论你在主菜单界面,还是在战斗界面,想上传文件时,直接找这位管家即可,不需要每次都 new 一个新管家。

  2. 异步与多线程 (async, await Task.Run)

    • 概念:Unity 的主线程就像是“前台接待员”,负责每秒画 60 次画面。以前我们让前台去搬砖(上传文件),所以前台没空画画面(游戏卡死)。现在,我们用 Task.Run 雇佣了一个“后台搬运工”(子线程) 去传文件。前台照常工作,两者互不干扰!

  3. 委托回调 (UnityAction action)

    • 概念:搬运工(子线程)传完文件后,怎么通知前台呢?action 就像是一个**“留言条”**。你告诉管家:“传完之后,帮我执行这张纸条上的事情(比如在屏幕上弹个‘上传成功’)”。

第二部分:老师修正后的代码与详细注释

using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Events; // 引入 UnityAction 所需的命名空间

// 注意:这个类没有继承 MonoBehaviour,它是一个纯 C# 管理类
public class FTPMgr 
{
    // ================== 1. 单例模式 ==================
    // 私有静态实例,类加载时就创建好
    private static FTPMgr instance = new FTPMgr();
    // 公开的属性,供外部访问(箭头函数 => 是 return instance; 的简写)
    public static FTPMgr Instance => instance;

    // ================== 2. 账号配置 ==================
    // 固定的服务器地址、账号、密码
    private string FTP_PATH = "ftp://127.0.0.1/";
    private string USER_NAME = "ddd";
    private string PASSWORD = "13246";

    // ================== 3. 异步上传方法 ==================
    // async 关键字:告诉编译器这个方法内部会有耗时操作,不要一直等它
    // fileName: 存到服务器上的名字 (例如 "icon.png")
    // localPath: 电脑上文件的路径 (例如 "C:/xxx/icon.png")
    // action: 上传结束后的回调函数(默认值为 null,表示可以不传)
    public async void UpLoadFile(string fileName, string localPath, UnityAction action = null)
    {
        // await: 前台执行到这里时,把任务丢给后台,前台自己去忙别的(游戏继续运行不卡顿)
        // Task.Run: 开辟一个子线程(雇佣搬运工)
        await Task.Run(() =>
        {
            // 以下代码全部在【子线程】中运行!
            try
            {
                // 1. 拼接完整路径并创建请求 (例如 ftp://127.0.0.1/icon.png)
                FtpWebRequest req = FtpWebRequest.Create(new Uri(FTP_PATH + fileName)) as FtpWebRequest;
                req.Credentials = new NetworkCredential(USER_NAME, PASSWORD);
                req.KeepAlive = false;
                req.UseBinary = true;
                req.Method = WebRequestMethods.Ftp.UploadFile;
                req.Proxy = null;

                // 2. 获取上传管道
                Stream upLoadStream = req.GetRequestStream();

                // 3. 打开本地文件
                using (FileStream fileStream = File.OpenRead(localPath))
                {
                    byte[] bytes = new byte[1024]; // 1KB 的水桶
                    int contentLength = fileStream.Read(bytes, 0, bytes.Length);

                    while (contentLength != 0)
                    {
                        // ★ 老师重点修正!!!致命 Bug!!!
                        // 错:upLoadStream.Write(bytes, 0, bytes.Length); 
                        // 对:upLoadStream.Write(bytes, 0, contentLength);
                        // 原因:如果是最后一次舀水,水桶可能没装满(比如只装了 200 字节)。
                        // 如果你写 bytes.Length (1024),就会把水桶里剩下的 824 字节“空气(乱码)”也传给服务器!导致文件损坏!
                        upLoadStream.Write(bytes, 0, contentLength);

                        // 继续舀下一桶
                        contentLength = fileStream.Read(bytes, 0, bytes.Length);
                    }
                    
                    // 管道关掉 (fileStream 因为有 using 会自动关,这里写不写都行)
                    upLoadStream.Close();
                }
                
                // Debug.Log 在较新的 Unity 版本中是可以在子线程调用的
                Debug.Log("后台传输完成!"); 
            }
            catch (Exception e)
            {
                Debug.Log("上传文件出错: " + e.Message);
            }
        }); 
        // --- Task.Run 的大括号结束 ---

        // ================== 4. 回到主线程执行回调 ==================
        // 因为前面有 await,所以走到这里时,Task 已经彻底执行完了。
        // 并且!await 会自动把执行权【切回 Unity 主线程】。
        // ?. 语法:如果 action 不是 null,就 Invoke() 执行它。
        action?.Invoke(); 
    }
}

第三部分:如何使用这个管理器?(实战演示)

using UnityEngine;

public class TestUpload : MonoBehaviour
{
    void Start()
    {
        // 假设我们有一张图片要上传
        string myLocalFile = Application.streamingAssetsPath + "/myHero.png";
        string targetName = "hero_backup.png";

        // 调用单例 (FTPMgr.Instance)
        // 传入一个“回调方法” (当上传完成时,帮我打印一段话)
        FTPMgr.Instance.UpLoadFile(targetName, myLocalFile, () => 
        {
            // 这段代码只有在文件真正传完后才会被触发!
            print("UI 提示:太棒了!文件已经成功上传到服务器!");
            // 这里可以安全地更新 UI,比如隐藏“正在上传”的转圈图标
            // loadingIcon.SetActive(false); 
        });

        print("发起上传请求完毕,游戏继续流畅运行,我不卡!");
    }
}

第四部分:总结、利弊与扩展

总结与利弊
  • 优点

    1. 全局通用:任何脚本都可以通过 FTPMgr.Instance 调用,代码极其干净。

    2. 不卡主线程:Task.Run 完美解决了网络请求假死的问题。

    3. UI 线程安全:await 机制极其优雅,它保证了 action?.Invoke() 是在 Unity 主线程执行的。这意味着你可以在 action 里去操作 Unity 的 UI(如果直接在 Task.Run 里操作 UI,Unity 会报错并崩溃)。

  • 缺点

    1. 如果玩家点击上传后,立马关闭了游戏或切到了其他场景,后台的 Task 还在默默运行,可能会导致找不到对象而报错。

    2. 缺乏取消机制。大文件传到一半想取消,目前的架构不支持。

扩展学习 (CancellationToken)

在未来的真实项目中,如果你想中途“取消”上传,你需要给 UpLoadFile 方法传入一个叫 CancellationToken 的参数。并在 while 循环里不断检查 if (token.IsCancellationRequested) break;。


第五部分:随堂测试 (Q&A)

检验学习成果的时候到了!

问题 1:为什么在原来的错误代码中,用 upLoadStream.Write(bytes,0,bytes.Length) 会导致图片传上去之后打不开(损坏)?

答案:假设最后一次读取本地文件时,只剩 100 个字节了。fileStream.Read 会把 100 字节放入水桶,此时 contentLength = 100。如果你坚持写 bytes.Length (1024),程序就会把水桶里那真实的 100 字节,加上原来残留在水桶里的 924 字节“旧数据”一起写进服务器。导致服务器收到的文件比本地文件大,且末尾全是垃圾数据,图片当然打不开。

问题 2:FTPMgr 为什么没有写 : MonoBehaviour?

答案:因为它不需要挂载到 Unity 的游戏物体(GameObject)上。它只是一个纯粹的逻辑管理类,通过 static 单例保存在内存中。这样更加轻量且易于管理。

问题 3:如果我想在上传成功后,把屏幕上的一个 Text 文本改成“上传成功”,这段改文本的代码应该写在 Task.Run 的大括号里面,还是通过传入 action 来执行?为什么?

答案:必须通过传入 action 来执行(或者写在 await Task.Run 之后)。绝不能写在 Task.Run 里面! 因为 Unity 有一个铁律:“所有与 Unity 引擎相关的组件(如 Transform, UI Text, GameObject 等),都只能在 Unity 的主线程中操作”。子线程修改 UI 会直接报错崩溃。

问题 4:action?.Invoke(); 里的 ?. 是什么意思?

答案:它是 C# 的“空值传播运算符”。相当于:
if (action != null) { action.Invoke(); }
这是为了防止调用者没有传回调函数(action 为空)时,直接调用 Invoke() 导致空指针异常报错。

第六部分:下载文件函数 DownLoadFile

下载和上传是镜像操作,区别在于我们要向服务器“索取”文件,而不是“发送”文件。

public async void DownLoadFile(string fileName, string localPath, UnityAction action = null)
{
    try // 注意:这里的try包在了Task.Run外面
    {
        await Task.Run(() =>
        {
            // 1. 依然是填写“快递单”
            FtpWebRequest req = FtpWebRequest.Create(new Uri(FTP_PATH + fileName)) as FtpWebRequest;
            req.Credentials = new NetworkCredential(USER_NAME, PASSWORD);
            req.KeepAlive = false;
            req.UseBinary = true;
            
            // 【关键区别】2. 告诉服务器,这次我要“下载文件” (DownloadFile)
            req.Method = WebRequestMethods.Ftp.DownloadFile;
            req.Proxy = null;

            // 3. 获取响应 (Response):向服务器递交单子后,服务器给我们的回应
            FtpWebResponse res = req.GetResponse() as FtpWebResponse;

            // 4. 从回应中获取下载流(接通一根从服务器通向我们本地的“进水管”)
            Stream downLoadStream = res.GetResponseStream();

            // 5. 在本地创建一个空文件,准备把水灌进去
            using(FileStream fileStream = File.Create(localPath))
            {
                byte[] bytes = new byte[1024]; // 再次准备1024的小推车

                // 从服务器水管里接水
                int contentLength = downLoadStream.Read(bytes, 0, bytes.Length);

                while(contentLength != 0) // 只要还能接到水
                {
                    // 把接到的水,倒进本地新建的文件里
                    fileStream.Write(bytes, 0, contentLength);

                    // 继续接下一车水
                    contentLength = downLoadStream.Read(bytes, 0, bytes.Length);
                }

                fileStream.Close(); // 关掉本地文件
                downLoadStream.Close(); // 关掉服务器的水管
            } 
        });   
    }
    catch (Exception e)
    {
        // 【老师纠错】:原代码这里是空的!如果出错,你根本不知道发生了什么。
        Debug.LogError("下载文件失败啦:" + e.Message);
    }
    
    // 执行回调
    action?.Invoke();
}
2. 参数与变量的意义
  • res (FtpWebResponse): 服务器的回复。你可以从里面提取出数据(流)、甚至状态码(比如问问服务器:你允许我下载吗?)。

  • downLoadStream: 从服务器源源不断传送过来数据的输入流

  • File.Create(localPath): 创建一个全新的文件。如果同名文件已存在,它会把原来的覆盖掉

3. 如何使用这个函数?

假设你需要从服务器下载最新的游戏配置文件:

string saveToLocal = Application.persistentDataPath + "/GameConfig.xml";
FTPMgr.Instance.DownLoadFile("LatestConfig.xml", saveToLocal, () => 
{
    Debug.Log("配置文件下载完毕,准备开始解析!");
    // 在这里写加载配置文件的代码...
});
4. 总结与扩展 (下载篇)
  • 弊端: 如果下载到 99% 断网了,下次调用这个函数又会从 0% 重新下载(因为用的是 File.Create)。

  • 扩展(断点续传): 要实现断点续传,你需要把 File.Create 换成 File.Append (追加模式),并且在向服务器发请求时,告诉它:req.ContentOffset = 本地已经下载的文件大小,这样服务器就会从断开的地方接着发给你。

5. 随堂测试 QA

Q2:代码里出现了 req.GetRequestStream() 和 res.GetResponseStream(),这两个有什么区别?

答案:

  • RequestStream 是你向外发送数据的通道(用于上传,相当于你的嘴巴在说话)。

  • ResponseStream 是你接收服务器数据的通道(用于下载,相当于你的耳朵在听服务器说话)。

Q3:为什么空的 catch (Exception e) { } 非常不好?

答案: 这叫“吞掉异常”。就像病人(代码)生病了在惨叫(报错),但是医生把他的嘴捂上了。如果下载失败了,游戏界面没反应,开发者看控制台也看不到报错,会导致查 Bug 极其困难!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐