核心概念:为什么在 Android 上访问 FTP 需要特别注意?
Android 运行在 Linux 内核上,其网络模型与桌面 Java 应用有所不同,最关键的一点是 网络限制:

- 主线程不能进行网络操作:从 Android 3.0 (Honeycomb) 开始,所有网络请求(如 Socket 连接、HTTP 请求、FTP 请求)都必须在 工作线程 中执行,如果在主线程(UI 线程)中执行,会抛出
NetworkOnMainThreadException异常,并可能导致应用被系统强制关闭。 - 权限:应用必须声明
INTERNET权限才能进行网络访问。
推荐的 FTP 库
虽然 Java 标准库中有 org.apache.commons.net.ftp.FTPClient,但它功能相对基础,且需要处理很多底层细节,对于 Android 开发,使用一个更现代、功能更强大的库是更好的选择。
强烈推荐 Apache Commons Net,它是一个成熟、稳定且功能丰富的网络库,支持 FTP、FTPS、SFTP 等多种协议。
为什么推荐它?
- 功能全面:支持文件上传、下载、目录操作、文件重命名、删除等。
- 支持 FTPS:可以轻松配置为使用 FTP over SSL/TLS,保证数据传输的安全性。
- 社区活跃:有大量的文档和示例代码,遇到问题容易找到解决方案。
项目配置
1 添加依赖
在你的 build.gradle (Module: app) 文件中添加 Apache Commons Net 的依赖。

dependencies {
// ... 其他依赖
implementation 'commons-net:commons-net:3.9.0' // 请使用最新版本
}
2 添加网络权限
在 app/src/main/AndroidManifest.xml 文件中添加 INTERNET 权限。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 添加网络权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<application
...>
...
</application>
</manifest>
代码实现示例
下面是一个完整的示例,展示如何在工作线程中连接 FTP 服务器、上传文件和下载文件。
1 添加布局文件 (activity_main.xml)
为了演示,我们添加几个按钮和一个日志显示区域。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:padding="16dp"
tools:context=".MainActivity">
<Button
android:id="@+id/btn_upload"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="上传文件" />
<Button
android:id="@+id/btn_download"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="下载文件" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="16dp">
<TextView
android:id="@+id/tv_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textSize="12sp" />
</ScrollView>
</LinearLayout>
2 主 Activity 代码 (MainActivity.kt)
这个示例展示了如何使用 AsyncTask(虽然已不推荐,但为了简单演示)来处理耗时操作,在实际项目中,你应该使用 Coroutine、RxJava 或 ExecutorService。

package com.example.ftpexample
import android.os.AsyncTask
import android.os.Bundle
import android.util.Log
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import org.apache.commons.net.ftp.FTP
import org.apache.commons.net.ftp.FTPClient
import org.apache.commons.net.ftp.FTPReply
import java.io.FileInputStream
import java.io.FileOutputStream
class MainActivity : AppCompatActivity() {
private lateinit var tvLog: TextView
// FTP 服务器配置 - 请替换为你的实际信息
private val ftpServer = "ftp.example.com"
private val ftpPort = 21
private val ftpUser = "username"
private val ftpPassword = "password"
private val remoteDir = "/remote/path/" // 远程服务器上的目录
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
tvLog = findViewById(R.id.tv_log)
val btnUpload = findViewById<Button>(R.id.btn_upload)
val btnDownload = findViewById<Button>(R.id.btn_download)
btnUpload.setOnClickListener {
// 示例:上传设备上的文件到FTP服务器
// 请确保 /storage/emulated/0/Download/test.txt 存在
val localFilePath = "/storage/emulated/0/Download/test.txt"
val remoteFileName = "uploaded_test.txt"
FtpUploadTask().execute(localFilePath, remoteFileName)
}
btnDownload.setOnClickListener {
// 示例:从FTP服务器下载文件到设备
val remoteFileName = "uploaded_test.txt"
val localFilePath = "/storage/emulated/0/Download/downloaded_test.txt"
FtpDownloadTask().execute(remoteFileName, localFilePath)
}
}
/**
* FTP 上传任务
* @param params[0] 本地文件路径
* @param params[1] 远程文件名
*/
inner class FtpUploadTask : AsyncTask<String, Void, String>() {
override fun doInBackground(vararg params: String?): String {
val localPath = params[0]
val remoteName = params[1]
if (localPath == null || remoteName == null) return "参数错误"
var ftpClient: FTPClient? = null
var fis: FileInputStream? = null
return try {
ftpClient = FTPClient()
ftpClient.connect(ftpServer, ftpPort)
if (!FTPReply.isPositiveCompletion(ftpClient.replyCode)) {
return "FTP服务器拒绝连接"
}
if (!ftpClient.login(ftpUser, ftpPassword)) {
return "用户名或密码错误"
}
// 设置文件类型为二进制,防止文件损坏
ftpClient.setFileType(FTP.BINARY_FILE_TYPE)
// 进入远程目录
if (ftpClient.remoteDirPath.isNotEmpty() && !ftpClient.changeWorkingDirectory(remoteDir)) {
return "无法进入远程目录: $remoteDir"
}
fis = FileInputStream(localPath)
val success = ftpClient.storeFile(remoteName, fis)
if (success) {
"文件上传成功: $remoteName"
} else {
"文件上传失败: ${ftpClient.replyString}"
}
} catch (e: Exception) {
Log.e("FTPUpload", "上传异常", e)
"上传异常: ${e.message}"
} finally {
try {
fis?.close()
ftpClient?.logout()
ftpClient?.disconnect()
} catch (e: Exception) {
Log.e("FTPUpload", "关闭连接异常", e)
}
}
}
override fun onPostExecute(result: String) {
addLog(result)
}
}
/**
* FTP 下载任务
* @param params[0] 远程文件名
* @param params[1] 本地保存路径
*/
inner class FtpDownloadTask : AsyncTask<String, Void, String>() {
override fun doInBackground(vararg params: String?): String {
val remoteName = params[0]
val localPath = params[1]
if (remoteName == null || localPath == null) return "参数错误"
var ftpClient: FTPClient? = null
var fos: FileOutputStream? = null
return try {
ftpClient = FTPClient()
ftpClient.connect(ftpServer, ftpPort)
if (!FTPReply.isPositiveCompletion(ftpClient.replyCode)) {
return "FTP服务器拒绝连接"
}
if (!ftpClient.login(ftpUser, ftpPassword)) {
return "用户名或密码错误"
}
ftpClient.setFileType(FTP.BINARY_FILE_TYPE)
if (ftpClient.remoteDirPath.isNotEmpty() && !ftpClient.changeWorkingDirectory(remoteDir)) {
return "无法进入远程目录: $remoteDir"
}
fos = FileOutputStream(localPath)
val success = ftpClient.retrieveFile(remoteName, fos)
if (success) {
"文件下载成功: $localPath"
} else {
"文件下载失败: ${ftpClient.replyString}"
}
} catch (e: Exception) {
Log.e("FTPDownload", "下载异常", e)
"下载异常: ${e.message}"
} finally {
try {
fos?.close()
ftpClient?.logout()
ftpClient?.disconnect()
} catch (e: Exception) {
Log.e("FTPDownload", "关闭连接异常", e)
}
}
}
override fun onPostExecute(result: String) {
addLog(result)
}
}
private fun addLog(message: String) {
runOnUiThread {
tvLog.append("$message\n")
}
}
}
安全性考虑:使用 FTPS
传统的 FTP 是不安全的,因为用户名、密码和所有数据都以明文传输,强烈建议使用 FTPS (FTP over SSL/TLS)。
要启用 FTPS,你需要修改连接和登录逻辑:
// 在 doInBackground 中修改连接和登录部分
try {
ftpClient = FTPClient()
// 1. 设置安全连接模式
ftpClient.enterLocalPassiveMode() // 被动模式,通常在防火墙后更友好
ftpClient.setConnectTimeout(10000)
ftpClient.setDefaultTimeout(10000)
// 2. 使用 Implicit (隐式) FTPS 或 Explicit (显式) FTPS
// 这里以 Explicit FTPS 为例,更常用
ftpClient.connectInsecure(ftpServer, ftpPort) // 使用 connectInsecure 避免SSL握手前的明文传输
if (!FTPReply.isPositiveCompletion(ftpClient.replyCode)) {
return "FTP服务器拒绝连接"
}
// 3. 升级到安全连接
if (!ftpClient.execPBSZ(0) || !ftpClient.execPROT("P")) {
return "无法升级到安全连接"
}
// 4. 登录
if (!ftpClient.login(ftpUser, ftpPassword)) {
return "用户名或密码错误"
}
// ... 后续代码保持不变 ...
ftpClient.setFileType(FTP.BINARY_FILE_TYPE)
// ...
} catch (e: Exception) {
// ...
}
注意:connectInsecure 是 Apache Commons Net 库提供的一个方法,用于建立初始的非加密连接,然后通过 execPBSZ 和 execPROT 命令来协商升级到加密连接,确保你的 FTP 服务器支持 Explicit FTPS。
最佳实践和注意事项
- 不要在主线程操作:再次强调,所有网络操作都必须在后台线程完成。
- 使用连接池:如果你需要频繁进行 FTP 操作,可以考虑创建一个
FTPClient连接池,复用连接,而不是每次都新建和销毁,这能显著提高性能。 - 错误处理:网络操作充满不确定性(网络中断、服务器宕机、权限变更等),务必使用
try-catch块来捕获所有可能的异常(IOException,SocketException等),并向用户友好的提示。 - 被动模式:在 Android 设备上,通常建议使用
ftpClient.enterLocalPassiveMode(),这可以解决很多由于 NAT 和防火墙导致的数据连接无法建立的问题。 - 超时设置:为
FTPClient设置合理的连接超时和默认超时,防止应用在无响应的情况下长时间挂起。ftpClient.connectTimeout = 10000 // 10秒连接超时 ftpClient.defaultTimeout = 60000 // 60秒操作超时
- UI 反馈:对于耗时的上传下载操作,一定要给用户反馈,例如显示一个进度条或加载动画,操作完成后显示成功或失败的提示。
- 考虑现代替代方案:对于新项目,请认真评估 FTP 是否是最佳选择,现代的 RESTful API 通常更灵活、更安全(HTTPS),并且更适合移动应用,如果必须使用文件传输协议,SFTP (基于 SSH) 也是一个比 FTP/FTPS 更安全的选择,尽管它需要不同的库(如 JSch)。
希望这份详细的指南能帮助你在 Android 应用中成功集成 FTP 功能!
