搞Unity开发的朋友应该都遇到过这类需求:游戏存档要备份、玩家截图想分享、设备日志要上报、数字孪生项目里的采集文件需要同步回服务端。很多人第一反应是走HTTP接口,让后端收文件,但真到了某些内网环境、工业现场或者需要对接甲方现成的文件服务器时,FTP还是最省事的方案。虽然FTP这协议年岁不小,但在Unity网络基础这块儿,把它用明白绝对是个实用技能。
这篇文章我用实际操作经验把"Unity里怎么通过FTP上传数据"这件事从头到尾捋一遍,包含服务器端怎么快速搭环境、Unity端怎么选实现方案、上传进度和断点续传怎么做,以及我踩过的那些坑的完整排查过程。不管你是刚接触Unity网络编程的新手,还是被FTP上传折磨过的老手,这篇都能给你一套能直接抄作业的思路。
1. 为什么还要在Unity项目里折腾FTP
1.1 FTP在Unity里的典型应用场景
有朋友会问,都什么年代了还用FTP,HTTP不香吗?说实话,在大多数互联网产品场景里,HTTP上传文件肯定更主流。但Unity的项目往往不只是手游,它有大量跑在工业、教育、数字孪生、展厅互动这些方向的落地场景,这些场景里FTP的出场率远比很多人想象的高。
我在实际项目里碰到过这几类需求:
- 内网环境的数据汇总:工厂产线上的工控机、展厅里的互动终端,往往部署在内网,没有公网HTTP服务可用,但有一台现成的FTP文件服务器,Unity客户端采集完数据直接传上去就行。
- 对接甲方已有设施:很多传统行业甲方根本没有研发团队写后端接口,但他们有NAS、有Windows共享目录、有配置好的FTP站点。你要做数据对接,只能按他们的规矩来。
- 日志和截图上报:做设备的远程运维时,用FTP把崩溃日志、运行日志、现场截图传回服务器,虽然土,但稳定,不需要额外开发Web服务端。
- 离线素材更新:部分展项、一体机项目的素材包、配置文件可以通过FTP同步,Unity端启动时检查版本,有更新就拉取。
1.2 先泼一盆冷水:FTP的明文传输问题
FTP最大的硬伤就是明文传输,用户名、密码、文件内容全部裸奔在网络上。如果你在公网环境传输敏感数据,FTP绝对不是好选择。Unity端的替代方案一般是HTTPS multipart上传、SFTP(走SSH通道的FTP)或者WebDAV,这些能加密数据,但服务端支持的门槛更高,对接成本也更大。
我的建议是按场景取舍:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 公网传输敏感数据 | HTTPS上传接口 | 加密传输,服务端可控 |
| 局域网/内网,数据不敏感 | FTP | 部署简单,兼容性强 |
| 已有SFTP服务器 | SSH.NET库 / SFTP | 加密传输,但依赖服务器支持 |
| 纯文件交换,不想写后端 | FTP | 现成服务端,开箱即用 |
后面正文的FTP实现方案,我会在两个前提下展开:一是传输数据不涉密,二是服务器已经提供FTP能力。真涉及敏感数据,请务必升级为FTPS(FTP over TLS)或者换SFTP方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先把服务器端跑通:本地FTP环境速建
2.1 选一个顺手的FTP服务器
在Windows上做开发测试,我一般直接用FileZilla Server,或者干脆用Windows自带的IIS FTP。FileZilla Server的好处是图形化界面,创建用户、分配目录、设置权限都是点几下的事,对我这种主要写Unity逻辑、不想折腾服务器的人来说非常省心。Linux服务器上则用vsftpd,仓库里直接装就行,配置也不复杂。
测试阶段我强烈建议本地搭一个。原因很简单:排查问题的时候,你能直接看到服务器日志,到底是账号不对、目录没权限,还是被动模式端口被防火墙拦了,一目了然。如果你直接连一个远端服务器,出了问题你连日志都看不着,只能瞎猜。
2.2 创建账号、目录和权限的底线配置
以FileZilla Server为例,最小可用的配置就三步:
- 创建用户:设置用户名和密码,比如我测试时常用
unity/unity123。 - 设置主目录:把某个本地目录挂给这个用户,比如
D:\FTPSite。 - 分配权限:至少勾选“读取”和“写入”,如果要创建目录,还得勾选“创建目录”。如果只传不删,不建议勾“删除”,避免程序误操作把服务器文件删了。
这些配置完成后,可以用Windows命令行试一下是否通:
bash复制ftp 192.168.1.100
输入用户名密码后,如果能进入FTP交互界面,说明服务器端没问题,可以开始写Unity代码了。
2.3 防火墙与被动模式端口
这是新手最容易忽略的地方。FTP有两个通道:命令通道走21端口,数据通道则分主动模式(Active Mode)和被动模式(Passive Mode)。Unity里用FtpWebRequest时一般设置 UsePassive = true,走被动模式,被动模式下服务器会额外开放一段端口范围用于数据传输,比如FileZilla Server默认可能用50000-51000。
所以在Windows防火墙里,除了放行21端口,还要放行这段数据端口范围。否则可能出现登录成功、但上传时卡住或者直接超时的诡异问题——命令通道通了,数据通道被拦了。
提示:在局域网测试环境,如果你搞不清防火墙规则,可以先临时把防火墙关掉验证一次。如果关掉就正常,那就是端口放行问题,再精确放行21和数据端口。
2.4 连接验证与常见状态码
连上FTP之后,服务器会返回一系列状态码,这些状态码对后面排查问题非常关键。我整理了一份高频状态码对照:
| 状态码 | 含义 | 常见场景 |
|---|---|---|
| 220 | 服务就绪 | 刚连上FTP服务器 |
| 230 | 登录成功 | 用户密码验证通过 |
| 331 | 需要密码 | 用户名正确,等待密码 |
| 425/426 | 数据连接无法建立/已关闭 | 被动模式端口被防火墙拦截 |
| 450/550 | 文件不可用/权限不足 | 目录不存在、目录无权限访问 |
| 500/502 | 命令无法执行/命令未实现 | 服务器不支持某个FTP命令 |
| 530 | 登录失败 | 用户名密码错误 |
| 226 | 传输完成 | 文件上传或下载成功 |
看到这些状态码,心里就有数了。比如登录失败报530,那基本不用查代码,先确认账号密码;报550,多半是目录路径或者权限的问题。
3. Unity端FTP上传的三种实现路线
3.1 为什么优先选FtpWebRequest
Unity里和FTP打交道,常见思路有三种:用.NET的FtpWebRequest、用UnityWebRequest、用第三方库。我的结论是:常规场景用FtpWebRequest就够了,可控性最强、坑最少。
FtpWebRequest是.NET框架里内置的类,Unity的Mono和IL2CPP都支持它,不需要额外引入任何库。它的API设计得比较底层,能让你精确控制FTP请求的Method、Credentials、UseBinary、UsePassive、ContentOffset、Timeout这些关键参数,很多服务器兼容性问题可以通过调这些参数解决。而UnityWebRequest虽然封装友好,但它对FTP的官方支持并不是重点,实际使用中对FTP命令细节的控制能力弱,遇到一些非标服务器会非常难受。
3.2 那UnityWebRequest是不是完全不能用
也不能一棍子打死。UnityWebRequest支持 ftp:// 协议的URI来上传文件,代码写起来很简单:
csharp复制UnityWebRequest request = new UnityWebRequest(ftpUrl, UnityWebRequest.kHttpVerbPUT);
request.uploadHandler = new UploadHandlerFile(localFilePath);
var asyncOp = request.SendWebRequest();
它的问题在于:你没法精细控制FTP握手细节。比如你想改被动模式、设置上传偏移、自定义超时,UnityWebRequest给不了你这些底层入口。有些FTP服务器(特别是配置不规范的)用UnityWebRequest能连上,有些就不行,排查起来非常被动,因为它不会把FTP服务器的详细响应返回给你。
所以我现在的习惯是:除非只是本地快速demo演示,否则一律用FtpWebRequest。要统一封装的话,我可以对外提供一套统一的IUploadService接口,内部用FtpWebRequest实现,将来要切HTTP或SFTP,上层调用的代码一点不用动。
3.3 第三方库要不要用
FluentFTP是.NET生态里非常成熟的FTP库,支持FTP/FTPS/SFTP,接口友好,功能完整。如果你对FTP的各种复杂操作需求特别多,比如列出目录、递归上传下载、断点续传、FTP服务器代理等,引入FluentFTP确实能省很多事。
但用第三方库前要评估三点:第一,Unity IL2CPP平台下是否兼容,尤其Android和iOS真机;第二,库的许可证是否允许商业项目闭源使用;第三,库的依赖是否会影响包体大小。FluentFTP本身还算轻量,但一定要在目标平台上先跑一个上传冒烟测试再决定,别等开发到一半才发现真机跑不通。
综合来看,如果你的需求就是“把文件传上去、能看进度、能处理常见错误”,FtpWebRequest完全够用,而且这篇文章后面讲的所有排查思路都是基于FtpWebRequest来展开的。
4. 手写一个可用的上传封装
4.1 基础版上传方法:核心参数拆解
先给一个最简化的上传方法,把每条参数的作用说清楚。
csharp复制using System;
using System.IO;
using System.Net;
public static class FtpUploader
{
public static bool UploadFile(string serverIp, string userName, string password,
string remotePath, string localFilePath, int timeoutSeconds = 30)
{
try
{
// 拼接FTP完整地址,注意路径分隔符用"/"
string url = "ftp://" + serverIp + "/" + remotePath;
FtpWebRequest request = (FtpWebRequest)WebRequest.Create(url);
request.Method = WebRequestMethods.Ftp.UploadFile;
request.Credentials = new NetworkCredential(userName, password);
request.UseBinary = true;
request.UsePassive = true;
request.KeepAlive = false;
request.Timeout = timeoutSeconds * 1000;
request.ReadWriteTimeout = timeoutSeconds * 1000;
using (FileStream fs = new FileStream(localFilePath, FileMode.Open, FileAccess.Read))
{
using (Stream requestStream = request.GetRequestStream())
{
fs.CopyTo(requestStream);
}
}
using (FtpWebResponse response = (FtpWebResponse)request.GetResponse())
{
Console.WriteLine($"[FTP] Upload complete: {response.StatusDescription}");
return response.StatusCode == FtpStatusCode.ClosingData;
}
}
catch (Exception e)
{
Console.Error.WriteLine($"[FTP] Upload failed: {e.Message}");
return false;
}
}
}
逐个解释为什么这些参数要这么设:
- Method = UploadFile:这是FTP协议的上传命令,等价于
STOR,覆盖写入远程文件。如果你想追加内容,应该用AppendFile。 - UseBinary = true:以二进制模式传输。做个好习惯,不管传什么文件都开二进制模式,避免文本模式下的换行符转换导致文件损坏。比如文本文件用ASCII模式传完,Windows和Linux换行符差异会改变文件内容,图片、压缩包等二进制文件更是必须用二进制模式。
- UsePassive = true:被动模式,客户端主动去连接服务器的数据端口。默认在大多数局域网环境下没问题,但如果你遇到只能主动模式的服务器,需要改成
false再试。 - KeepAlive = false:这个挺关键。默认是true,表示一个FtpWebRequest完成后底层连接不关闭,复用给下一个请求。在Unity里如果同时或者连续发很多请求,KeepAlive=true有时候会带来连接状态脏掉的问题。做上传这种短连接操作,设为false最省心,用完即断,不给服务器留一堆半开连接。
- Timeout/ReadWriteTimeout:超时控制,单位毫秒。这个也是实际项目中经常踩坑的地方,不设置的话有些网络环境下默认超时时间很长,一旦网络不通,界面能卡好几秒才报错。
4.2 上传进度如何实现
基础版用fs.CopyTo(requestStream)一把梭传完了,但实际项目中用户需要看到上传进度。FTP本身不提供文件级进度信息,进度只能靠客户端自己算:打开文件时拿到文件总字节数,写入RequestStream时边写边统计已经读了多少字节。
不过这里有个Unity的典型问题:如果直接在主线程做文件读和网络写,文件一大,UI就会卡死。所以必须把上传过程放到后台线程,然后用Unity主线程的调度器回传进度事件。
下面是一个在Unity里用协程+线程配合的参考实现:
csharp复制using System.Collections;
using System.IO;
using System.Net;
using System.Threading;
using UnityEngine;
public class FtpUploaderBehaviour : MonoBehaviour
{
private FileStream _fs;
private long _totalBytes;
private long _uploadedBytes;
private bool _isUploading;
private string _errorMessage;
private string _serverIp;
private string _userName;
private string _password;
private string _remotePath;
private string _localFilePath;
private bool _success;
public void StartUpload(string serverIp, string userName, string password,
string remotePath, string localFilePath)
{
_serverIp = serverIp;
_userName = userName;
_password = password;
_remotePath = remotePath;
_localFilePath = localFilePath;
StartCoroutine(UploadCoroutine());
}
private IEnumerator UploadCoroutine()
{
_isUploading = true;
_errorMessage = null;
_success = false;
// 先取文件大小
FileInfo fi = new FileInfo(_localFilePath);
_totalBytes = fi.Length;
_uploadedBytes = 0;
// 后台线程执行上传
Thread thread = new Thread(UploadThread);
thread.IsBackground = true;
thread.Start();
// 等待上传结束
while (_isUploading)
{
float progress = _totalBytes > 0 ? (float)_uploadedBytes / _totalBytes : 0f;
// 在这里回调给UI更新进度
Debug.Log($"上传进度:{progress * 100f:f1}%");
yield return null;
}
if (_success)
{
Debug.Log("上传完成");
}
else
{
Debug.LogError("上传失败:" + _errorMessage);
}
}
private void UploadThread()
{
try
{
string url = "ftp://" + _serverIp + "/" + _remotePath;
FtpWebRequest request = (FtpWebRequest)WebRequest.Create(url);
request.Method = WebRequestMethods.Ftp.UploadFile;
request.Credentials = new NetworkCredential(_userName, _password);
request.UseBinary = true;
request.UsePassive = true;
request.KeepAlive = false;
request.Timeout = 30000;
using (_fs = new FileStream(_localFilePath, FileMode.Open, FileAccess.Read))
{
using (Stream requestStream = request.GetRequestStream())
{
byte[] buffer = new byte[81920]; // 80KB,平衡CPU和网络效率
int bytesRead;
while ((bytesRead = _fs.Read(buffer, 0, buffer.Length)) > 0)
{
requestStream.Write(buffer, 0, bytesRead);
Interlocked.Add(ref _uploadedBytes, bytesRead);
}
}
}
using (FtpWebResponse response = (FtpWebResponse)request.GetResponse())
{
_success = response.StatusCode == FtpStatusCode.ClosingData;
}
}
catch (Exception e)
{
_errorMessage = e.Message;
}
finally
{
_isUploading = false;
}
}
}
这里有个细节,后台线程里更新_uploadedBytes时我用了Interlocked.Add,因为主线程的协程在读取这个字段,后台线程在写这个字段,多线程并发访问同一个变量必须保证原子性,否则可能读到脏数据。这是做跨线程进度上报时最容易忽略的问题。
4.3 上传完成后的二次验证
上传方法返回成功,不代表文件真的就对了。FTP协议只是说服务端接收完成,但接收下来的文件是否和本地一致,它不管。所以我在关键项目中会做一步“传输后校验”,不校验文件内容,至少校验服务端文件大小。
做法很简单:上传完成后再次发起一个FTP请求,获取服务端文件大小。
csharp复制private static long GetRemoteFileSize(string url, string userName, string password)
{
FtpWebRequest request = (FtpWebRequest)WebRequest.Create(url);
request.Method = WebRequestMethods.Ftp.GetFileSize;
request.Credentials = new NetworkCredential(userName, password);
request.UseBinary = true;
request.UsePassive = true;
using (FtpWebResponse response = (FtpWebResponse)request.GetResponse())
{
return response.ContentLength;
}
}
拿到远程大小,和本地FileInfo.Length比一比,如果数量级不一致,那基本是传输中出了问题。虽然这种对比不能100%保证内容一致(比如服务器磁盘满了但报错信息没被正确捕获),但它能帮你发现绝大多数低级错误,而且实现成本极低。
5. 进阶:队列、目录创建与断点续传
5.1 多文件上传队列
一个Unity项目里要上传的往往不止一个文件。常见诉求是:把某个文件夹下的多个文件依次传上去。
如果直接写个for循环、每个文件都开一个协程一起跑,一会儿服务器就被你干趴了,尤其是FTP服务器配置不高的时候,并发连接数一多就报连接失败。更科学的做法是维护一个上传队列,一个一个来。
我常用的是一个极简队列思路:
- 把所有待上传文件路径放进
Queue<string>。 - 从队列中取一个文件,开始上传。
- 上传完成后检查队列是否为空:
- 为空,则整体完成;
- 不为空,则继续取下一个文件。
对应到协程上,大致长这样:
csharp复制private Queue<string> _uploadQueue = new Queue<string>();
private IEnumerator ProcessQueue(string serverIp, string userName, string password,
string remoteRootDir, string localRootDir)
{
while (_uploadQueue.Count > 0)
{
string relativePath = _uploadQueue.Dequeue();
string localFilePath = Path.Combine(localRootDir, relativePath);
string remoteFilePath = CombineFtpPath(remoteRootDir, relativePath);
yield return UploadSingleFile(serverIp, userName, password, remoteFilePath, localFilePath);
}
}
这样设计的好处是模块清晰,也方便做重试、断点续传等扩展。注意远程路径的拼接,FTP协议统一用 / 分隔路径,不要用Windows的 \,否则服务器解析路径时容易出问题。
5.2 目录自动创建:一个容易忽略的细节
上传文件时如果远程目录还不存在,FTP服务器会直接报550,上传失败。很多人在测试时手动建好了目录,一上生产环境就忘记处理目录创建逻辑。
FTP协议里创建目录对应MKD命令,在FtpWebRequest里就是WebRequestMethods.Ftp.MakeDirectory。但这里有个坑:如果目录已经存在,MKD命令会返回550“目录已存在”错误。你不能把这个错误当成失败,得识别出来。
我的处理方式是:先尝试创建目录,捕获异常,再看状态码:
csharp复制public static void EnsureRemoteDirectory(string url, string userName, string password)
{
FtpWebRequest request = (FtpWebRequest)WebRequest.Create(url);
request.Method = WebRequestMethods.Ftp.MakeDirectory;
request.Credentials = new NetworkCredential(userName, password);
request.UsePassive = true;
try
{
using (FtpWebResponse response = (FtpWebResponse)request.GetResponse())
{
// 创建成功
}
}
catch (WebException ex)
{
FtpWebResponse response = ex.Response as FtpWebResponse;
if (response != null && response.StatusCode == FtpStatusCode.ActionNotTakenFileUnavailable)
{
// 目录已存在,忽略
}
else
{
throw;
}
}
}
这种判断在递归创建多级目录时非常有用。比如远程路径是 /data/2025/06/screenshots,就需要逐级检查并创建每一层目录,整个过程用上面的方法就能稳当地跑下来。
5.3 断点续传的两种实现
大文件上传时,网络抖动是常有的事。FTP协议本身支持断点续传,但Unity里用FtpWebRequest来实现需要踩一点门槛。
FtpWebRequest提供了ContentOffset属性,从指定字节偏移位置重新开始传输。但前提是服务器支持REST命令。实现思路如下:
- 先查一下远程文件已经传了多少字节,比如通过
GetFileSize。 - 如果远程大小等于0,直接从头传。
- 如果远程大小小于本地大小,设置
request.ContentOffset = remoteSize,从断点继续上传。 - 如果远程大小等于本地大小,说明文件已经传完,直接跳过。
这里有个实现细节:设置ContentOffset后,你仍然要用UploadFile命令,FtpWebRequest会帮你发REST命令再发STOR。有些服务器不完全支持这种组合,所以更稳妥的做法是直接使用WebRequestMethods.Ftp.AppendFile,也就是APPE命令,它会自动从文件末尾追加数据。
csharp复制request.Method = WebRequestMethods.Ftp.AppendFile;
Append方式的好处是逻辑更简单,不需要先查再传,直接写。但它的限制是:如果远程文件不存在,部分服务器报错,部分服务器会自动创建,行为不一致。所以通常我会先做一次判断:
- 远程文件不存在或大小为0 → 用UploadFile
- 远程文件大小 < 本地文件大小 → 用UploadFile + ContentOffset或AppendFile
- 远程文件大小 >= 本地文件大小 → 跳过
断点续传看起来美好,但实际项目里我还是建议慎用。因为FTP服务器的实现五花八门,有些服务器的REST和APPE命令表现不标准,调试成本高。如果你的网络环境不太差,优先保证上传失败后的重试机制——整个文件重传,比断点续传更容易判断对错。断点续传作为增强项,等基础功能稳定后再考虑。
6. 实战中踩过的坑:排查链路与修复记录
6.1 连不上或超时的排查顺序
我在项目中碰到最频繁的问题是“连接超时”。很多时候代码写好了,一运行就卡在GetRequestStream()这一步,既不报错也不返回,直到超时。
遇到这种情况,我建议按顺序排查:
- 先确认不是代码问题:用FTP客户端(比如FileZilla Client)手动连一下同一个服务器,看能不能连通。如果客户端也连不上,说明服务器或网络有问题,先别折腾Unity代码。
- 检查IP和端口:Unity里写的是
ftp://192.168.1.100/...还是ftp://192.168.1.100:21/...?默认端口是21,如果你服务器改成了其他端口,URL里必须显式带上端口号。 - 防火墙和数据端口:前面提到过,被动模式下数据端口没放行,上传就会卡住。命令通道通了不代表数据通道通了。
- 主动/被动模式切换:把
UsePassive从true改成false试试,有些服务器在NAT环境下主动模式反而更稳,但Unity在Android/ iOS设备上UsePassive=false未必能工作,因为主动模式需要客户端开放一个监听端口。
6.2 登录报530/502的常见原因
登录报530,基本就是用户名密码错误,没什么好说的。但有一种情况容易忽略:密码里带了特殊字符,比如@、#、:,而你在拼接URL时没有做URL编码。
FTP的URL是 ftp://user:password@host/path 这种格式,如果密码里有@,URL解析器会把后面的部分当成host,直接登录失败。我的习惯是密码里尽量避免特殊字符,如果服务器已经定了特殊字符,就用System.Uri.EscapeDataString对用户名和密码做转义,然后再拼URL。
另外注意:NetworkCredential的密码参数是直接传原始字符串的,不需要URL编码;问题是如果你把用户名密码放进URL字符串里,那才需要编码。两种方式别搞混。
6.3 Android和iOS的差异化问题
Unity里的FtpWebRequest在编辑器环境下跑得很欢,一打包到Android真机就歇菜,这类问题我遇到好多次。最常见的两个原因:
Android端的明文流量限制:从Android 9(API 28)开始,系统默认禁止应用使用明文HTTP/FTP流量。FTP默认不走TLS,所以属于明文流量,会被系统直接拦掉。解决办法是在AndroidManifest里配置android:usesCleartextTraffic="true",或者使用网络安全配置只对特定域名放行明文流量。
iOS的ATS限制:App Transport Security对非HTTPS连接默认严格限制,FTP同样会被拒。需要在Info.plist里添加NSAppTransportSecurity,把NSAllowsArbitraryLoads设为true(开发阶段),或者用NSExceptionDomains针对FTP服务器域名放行。
这里有一个我踩过的坑:只改代码不干预平台配置,编辑器能通,真机必然失败。排查时如果本地测试正常、真机不正常,先别查FTP代码,优先检查平台网络权限配置。
6.4 中文文件名和路径分隔符
FTP协议对文件名编码的支持比较混乱,老服务器默认用ANSI编码(Windows GBK),新服务器很多用UTF-8。在Unity端,如果远程文件名是中文,直接传可能导致服务器上显示乱码。
处理办法是:在构造FtpWebRequest时,把文件名部分用Encoding.UTF8.GetBytes转成UTF-8字节流,重新拼到URI里。不过这种做法对服务器端编码有要求,如果服务器是GBK,你转成UTF-8反而更乱。更保险的方案是:规划远程目录结构时,尽量使用英文或拼音命名文件,省掉编码转换的麻烦。
路径分隔符问题在Windows下很典型:Path.Combine生成的是 \,而FTP协议要求 /。我封装时统一用一个函数处理:
csharp复制private static string ToFtpPath(string localPath)
{
return localPath.Replace('\\', '/');
}
这种低级错误,往往是本地调试没问题,上线后Linux服务器上找不到文件时才暴露出来。
6.5 平台差异:编辑器与真机的Uri解析不一致
Unity编辑器跑的是.NET Framework,Android真机跑的是Mono/IL2CPP,两者对Uri的解析行为有细微差异。我碰到过一种情况:本地new Uri("ftp://192.168.1.100/测试目录/file.zip")能正确解析,打包到Android上就抛UriFormatException,原因就是非ASCII字符的处理差异。所以在拼接FTP URL时,我尽量对路径片段做一次Uri.EscapeUriString,虽然这方法对中文转义不够彻底,但比裸奔强。
如果项目里文件路径包含中文较多,最稳的方式是先转成Uri再取AbsoluteUri,让框架帮你处理转义:
csharp复制Uri uri = new Uri("ftp://192.168.1.100/测试目录/file.zip");
string safeUrl = uri.AbsoluteUri;
总之,所有路径拼接逻辑都要在打包平台上跑一遍,别只信编辑器。
7. 回头再看:这套上传方案的适用范围
FTP上传这个能力,单拆开看不复杂,但放到Unity项目里,要照顾好线程、平台权限、服务器兼容性,还是有东西值得记录下来的。我自己在项目里一般会把FTP上传封装成一个独立的服务类,内部实现队列、进度回调、错误回调,外部只暴露UploadFiles(List<string> localPaths, string remoteDir, Action<float> onProgress, Action<bool> onComplete)这样的接口。这样即便以后后端要求改成HTTP上传,或者甲方终于换成了SFTP,上层业务逻辑可以不改,我只需要替换服务类的实现。
最后分享一个小技巧:在Unity编辑器里调试时,不要只连本机的FTP服务器。有条件的话在局域网里准备一台NAS或者Linux服务器,把FTP也搭起来,模拟真实环境的各种状态码。很多问题只有换了环境才会暴露,比如被动模式端口配置、文件权限、路径大小写敏感等。开发阶段多踩几个坑,上线的时候就能少被客户那边的运维骂几次。
这套从服务器搭建到Unity端编码、再到排错思路的完整链路,希望能帮你在自己的项目里少走弯路。如果你的服务器支持TLS,建议把request.EnableSsl = true加上,配合ftp://改成ftps://,能解决不少安全审查上的麻烦。不过那是另一个话题了,等大家把基础版跑通后再折腾也不迟。
