详细的FTP传输实现方案,包括完整代码、安全实践、性能优化和实际应用场景分析。
一、FTP传输类型对比表(增强版)
特性 | 非加密FTP | FTPS (FTP over SSL/TLS) | SFTP (SSH File Transfer Protocol) |
---|---|---|---|
协议基础 | FTP (RFC 959) | FTP + SSL/TLS (RFC 4217) | SSH-2 (RFC 4253) |
默认端口 | 21 (控制), 20 (数据) | 990 (隐式), 21 (显式) | 22 |
加密方式 | 无 | SSL/TLS加密 | SSH加密隧道 |
数据完整性 | 无 | SSL/TLS保证 | SSH保证 |
认证机制 | 用户名/密码 | 用户名/密码 + 证书 | 用户名/密码 + 密钥对 |
防火墙友好 | 差 (需要多个端口) | 中等 (需要控制+数据端口) | 优 (单一端口) |
Android实现库 | Apache Commons Net | Apache Commons Net | JSch/SSHJ |
性能开销 | 最低 | 中等 (10-15%) | 中等 (8-12%) |
推荐场景 | 内部测试网络 | 企业级文件传输 | 互联网文件传输 |
二、完整实现方案
1. 项目配置
build.gradle (模块级):
dependencies {// FTP/FTPS 实现implementation 'commons-net:commons-net:3.9.0'// SFTP 实现 (选择其一)implementation 'com.jcraft:jsch:0.1.55' // 方案1implementation 'com.hierynomus:sshj:0.35.0' // 方案2(更现代)// 后台任务implementation 'androidx.work:work-runtime:2.7.1'// 安全存储implementation 'androidx.security:security-crypto:1.1.0-alpha03'// 日志implementation 'com.jakewharton.timber:timber:5.0.1'
}
AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /><!-- 存储权限处理 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"tools:ignore="ScopedStorage" />
2. 非加密FTP实现(增强版)
import org.apache.commons.net.ftp.FTP;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPReply;import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.concurrent.TimeUnit;public class FtpUploader {private static final int CONNECT_TIMEOUT = 30; // 秒private static final int DATA_TIMEOUT = 120; // 秒public static FtpResult uploadFile(String server, int port, String username, String password, String localPath, String remoteDir, String remoteFileName) {FTPClient ftpClient = new FTPClient();FileInputStream inputStream = null;FtpResult result = new FtpResult();try {// 1. 配置客户端ftpClient.setConnectTimeout((int) TimeUnit.SECONDS.toMillis(CONNECT_TIMEOUT));ftpClient.setDataTimeout((int) TimeUnit.SECONDS.toMillis(DATA_TIMEOUT));ftpClient.setControlEncoding("UTF-8");// 2. 连接服务器ftpClient.connect(server, port);int replyCode = ftpClient.getReplyCode();if (!FTPReply.isPositiveCompletion(replyCode)) {result.error = "FTP服务器拒绝连接。响应代码: " + replyCode;return result;}// 3. 登录认证if (!ftpClient.login(username, password)) {result.error = "FTP登录失败。请检查凭据。";return result;}// 4. 配置传输模式ftpClient.enterLocalPassiveMode(); // 重要:应对防火墙/NATftpClient.setFileType(FTP.BINARY_FILE_TYPE);// 5. 创建远程目录(如果需要)if (remoteDir != null && !remoteDir.isEmpty()) {createDirectoryTree(ftpClient, remoteDir);}// 6. 上传文件File localFile = new File(localPath);if (!localFile.exists()) {result.error = "本地文件不存在: " + localPath;return result;}inputStream = new FileInputStream(localFile);String remotePath = (remoteDir != null ? remoteDir + "/" : "") + remoteFileName;long startTime = System.currentTimeMillis();boolean success = ftpClient.storeFile(remotePath, inputStream);long duration = System.currentTimeMillis() - startTime;if (success) {result.success = true;result.fileSize = localFile.length();result.durationMs = duration;Timber.d("FTP上传成功: %d bytes, 耗时: %d ms", result.fileSize, result.durationMs);} else {result.error = "文件存储失败。服务器响应: " + ftpClient.getReplyString();}} catch (Exception e) {result.error = "FTP异常: " + e.getMessage();Timber.e(e, "FTP上传失败");} finally {// 7. 清理资源try {if (inputStream != null) inputStream.close();if (ftpClient.isConnected()) {ftpClient.logout();ftpClient.disconnect();}} catch (IOException e) {Timber.e(e, "FTP清理资源时出错");}}return result;}private static void createDirectoryTree(FTPClient ftpClient, String path) throws IOException {String[] pathElements = path.split("/");if (pathElements.length > 0 && pathElements[0].isEmpty()) {pathElements[0] = "/";}for (String element : pathElements) {if (element.isEmpty()) continue;// 检查目录是否存在if (!ftpClient.changeWorkingDirectory(element)) {// 目录不存在则创建if (ftpClient.makeDirectory(element)) {ftpClient.changeWorkingDirectory(element);} else {throw new IOException("无法创建目录: " + element);}}}}public static class FtpResult {public boolean success = false;public long fileSize = 0;public long durationMs = 0;public String error = null;}
}
3. FTPS实现(增强安全版)
import org.apache.commons.net.ftp.FTPSClient;
import org.apache.commons.net.util.TrustManagerUtils;import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.File;
import java.io.FileInputStream;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;public class FtpsUploader {public static FtpResult uploadFile(String server, int port, String username, String password, String localPath, String remoteDir, String remoteFileName, boolean explicit, boolean validateCert) {FTPSClient ftpsClient;if (explicit) {// 显式 FTPS (FTPES)ftpsClient = new FTPSClient("TLS");} else {// 隐式 FTPSftpsClient = new FTPSClient(true); }// 配置SSL上下文try {SSLContext sslContext = SSLContext.getInstance("TLS");if (validateCert) {// 生产环境:使用系统默认信任管理器sslContext.init(null, null, null);} else {// 测试环境:接受所有证书(不推荐生产使用)TrustManager[] trustAllCerts = new TrustManager[]{new X509TrustManager() {public X509Certificate[] getAcceptedIssuers() {return new X509Certificate[0];}public void checkClientTrusted(X509Certificate[] certs, String authType) {}public void checkServerTrusted(X509Certificate[] certs, String authType) {}}};sslContext.init(null, trustAllCerts, null);}ftpsClient.setSSLContext(sslContext);} catch (NoSuchAlgorithmException | KeyManagementException e) {FtpResult result = new FtpResult();result.error = "SSL配置失败: " + e.getMessage();return result;}// 设置其他参数ftpsClient.setConnectTimeout(30000);ftpsClient.setDataTimeout(120000);// 启用服务器主机名验证ftpsClient.setHostnameVerifier((hostname, session) -> true); // 生产环境应实现验证try {// 连接服务器ftpsClient.connect(server, port);// 显式模式需要发送"AUTH TLS"命令if (explicit) {ftpsClient.execPROT("P"); // 保护数据通道}// 登录和文件传输逻辑与非加密FTP类似...// 参考FtpUploader的实现,添加以下安全步骤:// 登录后启用安全数据通道ftpsClient.execPBSZ(0); // 设置保护缓冲区大小ftpsClient.execPROT("P"); // 设置数据通道保护// ... 其余上传逻辑与FtpUploader相同} catch (Exception e) {// 错误处理} finally {// 清理资源}// 返回结果...}
}
4. SFTP实现(使用SSHJ - 现代方案)
import net.schmizz.sshj.SSHClient;
import net.schmizz.sshj.sftp.SFTPClient;
import net.schmizz.sshj.transport.verification.PromiscuousVerifier;
import net.schmizz.sshj.xfer.FileSystemFile;import java.io.File;
import java.io.IOException;
import java.security.PublicKey;public class SftpUploader {public static SftpResult uploadFile(String server, int port, String username,String password, String localPath,String remoteDir, String remoteFileName,boolean verifyHostKey) {SSHClient sshClient = new SSHClient();SftpResult result = new SftpResult();try {// 1. 配置SSH客户端sshClient.addHostKeyVerifier(new PromiscuousVerifier() {@Overridepublic boolean verify(String hostname, int port, PublicKey key) {if (verifyHostKey) {// 生产环境应验证主机密钥// 实现方式:将已知主机密钥存储在安全位置并比较return super.verify(hostname, port, key);}return true; // 测试环境跳过验证}});sshClient.setConnectTimeout(30000);sshClient.setTimeout(120000);// 2. 连接服务器sshClient.connect(server, port);// 3. 认证sshClient.authPassword(username, password);// 可选:密钥认证// sshClient.authPublickey(username, "path/to/private/key");// 4. 创建SFTP客户端try (SFTPClient sftpClient = sshClient.newSFTPClient()) {// 5. 创建远程目录(如果需要)if (remoteDir != null && !remoteDir.isEmpty()) {createRemoteDirectory(sftpClient, remoteDir);}// 6. 上传文件String remotePath = remoteDir + "/" + remoteFileName;File localFile = new File(localPath);long startTime = System.currentTimeMillis();sftpClient.put(new FileSystemFile(localFile), remotePath);long duration = System.currentTimeMillis() - startTime;result.success = true;result.fileSize = localFile.length();result.durationMs = duration;Timber.d("SFTP上传成功: %d bytes, 耗时: %d ms", result.fileSize, result.durationMs);}} catch (Exception e) {result.error = "SFTP错误: " + e.getMessage();Timber.e(e, "SFTP上传失败");} finally {try {sshClient.disconnect();} catch (IOException e) {Timber.e(e, "关闭SSH连接时出错");}}return result;}private static void createRemoteDirectory(SFTPClient sftp, String path) throws IOException {String[] folders = path.split("/");String currentPath = "";for (String folder : folders) {if (folder.isEmpty()) continue;currentPath += "/" + folder;try {sftp.lstat(currentPath); // 检查目录是否存在} catch (IOException e) {// 目录不存在则创建sftp.mkdir(currentPath);}}}public static class SftpResult {public boolean success = false;public long fileSize = 0;public long durationMs = 0;public String error = null;}
}
5. 后台任务管理(WorkManager增强版)
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.work.Data;
import androidx.work.Worker;
import androidx.work.WorkerParameters;public class FileUploadWorker extends Worker {private static final String KEY_SERVER = "server";private static final String KEY_PORT = "port";private static final String KEY_USERNAME = "username";private static final String KEY_PASSWORD = "password";private static final String KEY_LOCAL_PATH = "local_path";private static final String KEY_REMOTE_DIR = "remote_dir";private static final String KEY_REMOTE_FILE = "remote_file";private static final String KEY_PROTOCOL = "protocol"; // "ftp", "ftps", "sftp"private static final String KEY_VERIFY_CERT = "verify_cert"; // 仅FTPS/SFTPpublic FileUploadWorker(@NonNull Context context, @NonNull WorkerParameters params) {super(context, params);}@NonNull@Overridepublic Result doWork() {Data inputData = getInputData();// 从输入数据中提取参数String server = inputData.getString(KEY_SERVER);int port = inputData.getInt(KEY_PORT, 21);String username = inputData.getString(KEY_USERNAME);String password = inputData.getString(KEY_PASSWORD);String localPath = inputData.getString(KEY_LOCAL_PATH);String remoteDir = inputData.getString(KEY_REMOTE_DIR);String remoteFile = inputData.getString(KEY_REMOTE_FILE);String protocol = inputData.getString(KEY_PROTOCOL);boolean verifyCert = inputData.getBoolean(KEY_VERIFY_CERT, false);// 根据协议选择上传方法try {boolean success;switch (protocol) {case "ftps":// FTPS可以使用显式或隐式模式boolean explicit = port == 21; // 通常显式模式使用21端口FtpsUploader.FtpResult ftpsResult = FtpsUploader.uploadFile(server, port, username, password, localPath, remoteDir, remoteFile, explicit, verifyCert);success = ftpsResult.success;break;case "sftp":SftpUploader.SftpResult sftpResult = SftpUploader.uploadFile(server, port, username, password, localPath, remoteDir, remoteFile, verifyCert);success = sftpResult.success;break;case "ftp":default:FtpUploader.FtpResult ftpResult = FtpUploader.uploadFile(server, port, username, password, localPath, remoteDir, remoteFile);success = ftpResult.success;}return success ? Result.success() : Result.failure();} catch (Exception e) {return Result.failure();}}// 创建上传任务的方法public static void enqueueUpload(Context context, String protocol, String server, int port, String username, String password, String localPath, String remoteDir, String remoteFile,boolean verifyCert) {Data inputData = new Data.Builder().putString(KEY_PROTOCOL, protocol).putString(KEY_SERVER, server).putInt(KEY_PORT, port).putString(KEY_USERNAME, username).putString(KEY_PASSWORD, password).putString(KEY_LOCAL_PATH, localPath).putString(KEY_REMOTE_DIR, remoteDir).putString(KEY_REMOTE_FILE, remoteFile).putBoolean(KEY_VERIFY_CERT, verifyCert).build();Constraints constraints = new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).setRequiresBatteryNotLow(true).build();OneTimeWorkRequest uploadRequest = new OneTimeWorkRequest.Builder(FileUploadWorker.class).setInputData(inputData).setConstraints(constraints).setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS).build();WorkManager.getInstance(context).enqueue(uploadRequest);}
}
6. 安全凭证管理(使用Android Keystore)
import android.content.Context;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.util.Base64;import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;import java.nio.charset.StandardCharsets;
import java.security.KeyStore;public class SecureCredentialManager {private static final String PREFS_NAME = "secure_ftp_prefs";private static final String KEY_SERVER = "server";private static final String KEY_USERNAME = "username";private static final String KEY_PASSWORD = "password";public static void saveCredentials(Context context, String server, String username, String password) {try {MasterKey masterKey = new MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build();EncryptedSharedPreferences sharedPreferences =(EncryptedSharedPreferences) EncryptedSharedPreferences.create(context,PREFS_NAME,masterKey,EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);sharedPreferences.edit().putString(KEY_SERVER, server).putString(KEY_USERNAME, username).putString(KEY_PASSWORD, password).apply();} catch (Exception e) {Timber.e(e, "保存凭证失败");}}public static Credentials getCredentials(Context context) {try {MasterKey masterKey = new MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build();EncryptedSharedPreferences sharedPreferences =(EncryptedSharedPreferences) EncryptedSharedPreferences.create(context,PREFS_NAME,masterKey,EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);String server = sharedPreferences.getString(KEY_SERVER, null);String username = sharedPreferences.getString(KEY_USERNAME, null);String password = sharedPreferences.getString(KEY_PASSWORD, null);if (server != null && username != null && password != null) {return new Credentials(server, username, password);}} catch (Exception e) {Timber.e(e, "获取凭证失败");}return null;}public static class Credentials {public final String server;public final String username;public final String password;public Credentials(String server, String username, String password) {this.server = server;this.username = username;this.password = password;}}
}
三、文件路径处理(兼容Android 11+)
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.DocumentsContract;
import android.provider.MediaStore;import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;public class FileUtils {public static String getRealPath(Context context, Uri uri) {if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {return handleScopedStorage(context, uri);} else {return handleLegacyStorage(context, uri);}}private static String handleScopedStorage(Context context, Uri uri) {if (DocumentsContract.isDocumentUri(context, uri)) {final String docId = DocumentsContract.getDocumentId(uri);final String[] split = docId.split(":");final String type = split[0];if ("primary".equalsIgnoreCase(type)) {return Environment.getExternalStorageDirectory() + "/" + split[1];}} // 处理其他存储提供程序...return null;}private static String handleLegacyStorage(Context context, Uri uri) {if ("content".equalsIgnoreCase(uri.getScheme())) {String[] projection = { MediaStore.Images.Media.DATA };try (Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null)) {if (cursor != null && cursor.moveToFirst()) {int columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);return cursor.getString(columnIndex);}}}// 处理文件URIelse if ("file".equalsIgnoreCase(uri.getScheme())) {return uri.getPath();}return null;}public static File createTempFile(Context context, InputStream inputStream, String fileName) throws IOException {File outputDir = context.getCacheDir();File outputFile = new File(outputDir, fileName);try (FileOutputStream outputStream = new FileOutputStream(outputFile)) {byte[] buffer = new byte[4 * 1024];int read;while ((read = inputStream.read(buffer)) != -1) {outputStream.write(buffer, 0, read);}outputStream.flush();}return outputFile;}
}
四、最佳实践与高级主题
1. 安全实践
- 证书固定:对于FTPS/SFTP,实现证书固定以防止中间人攻击
- 双因素认证:使用密钥+密码组合进行SFTP认证
- 连接复用:为频繁传输建立持久连接
- 传输加密:即使使用SFTP,也可在应用层对敏感文件进行额外加密
2. 性能优化
- 分块传输:大文件使用分块上传/下载
- 并行传输:多个文件同时传输
- 压缩传输:在传输前压缩文本/日志文件
- 增量同步:仅传输变化部分
3. 错误处理与重试
public class UploadManager {private static final int MAX_RETRIES = 3;private static final long RETRY_DELAY_MS = 5000;public static boolean uploadWithRetry(String protocol, /* 参数 */) {int attempt = 0;boolean success = false;while (attempt < MAX_RETRIES && !success) {try {switch (protocol) {case "ftp":success = FtpUploader.uploadFile(/* 参数 */).success;break;case "ftps":success = FtpsUploader.uploadFile(/* 参数 */).success;break;case "sftp":success = SftpUploader.uploadFile(/* 参数 */).success;break;}} catch (Exception e) {Timber.e(e, "上传尝试 %d 失败", attempt + 1);}if (!success) {attempt++;if (attempt < MAX_RETRIES) {try {Thread.sleep(RETRY_DELAY_MS);} catch (InterruptedException ie) {Thread.currentThread().interrupt();}}}}return success;}
}
4. 协议选择指南
场景 | 推荐协议 | 理由 |
---|---|---|
内部网络,非敏感数据 | 标准FTP | 简单、高效、低开销 |
企业级文件传输 | FTPS (显式) | 兼容性好,企业防火墙通常支持 |
互联网文件传输 | SFTP | 单一端口,高安全性,NAT穿透性好 |
需要严格审计 | SFTP + 密钥认证 | 提供强身份验证和不可否认性 |
移动网络环境 | SFTP | 更好的连接稳定性,单一端口 |
五、完整工作流程
六、常见问题解决方案
-
连接超时问题
- 增加超时设置:
ftpClient.setConnectTimeout(60000)
- 检查网络策略:确保应用不在后台受限
- 尝试被动/主动模式切换
- 增加超时设置:
-
文件权限问题
// 在AndroidManifest.xml中添加 <applicationandroid:requestLegacyExternalStorage="true"...>
-
证书验证失败
- 开发环境:使用
TrustManagerUtils.getAcceptAllTrustManager()
- 生产环境:将服务器证书打包到应用中并验证
- 开发环境:使用
-
大文件传输稳定性
- 实现分块传输
- 添加进度保存和断点续传
- 使用
WorkManager
的持久化工作
-
Android 12+ 网络限制
- 在
AndroidManifest.xml
中添加:<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <service android:name=".FileTransferService"android:foregroundServiceType="dataSync" />
- 在
这个增强版实现方案提供了完整的FTP传输解决方案,包括安全实践、性能优化和兼容性处理,适合在生产环境中使用。