凌峰创科服务平台

Android如何访问FTP服务器?

核心概念:为什么在 Android 上访问 FTP 需要特别注意?

Android 运行在 Linux 内核上,其网络模型与桌面 Java 应用有所不同,最关键的一点是 网络限制

Android如何访问FTP服务器?-图1
(图片来源网络,侵删)
  • 主线程不能进行网络操作:从 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 的依赖。

Android如何访问FTP服务器?-图2
(图片来源网络,侵删)
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(虽然已不推荐,但为了简单演示)来处理耗时操作,在实际项目中,你应该使用 CoroutineRxJavaExecutorService

Android如何访问FTP服务器?-图3
(图片来源网络,侵删)
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) {
    // ...
}

注意connectInsecureApache Commons Net 库提供的一个方法,用于建立初始的非加密连接,然后通过 execPBSZexecPROT 命令来协商升级到加密连接,确保你的 FTP 服务器支持 Explicit FTPS。


最佳实践和注意事项

  1. 不要在主线程操作:再次强调,所有网络操作都必须在后台线程完成。
  2. 使用连接池:如果你需要频繁进行 FTP 操作,可以考虑创建一个 FTPClient 连接池,复用连接,而不是每次都新建和销毁,这能显著提高性能。
  3. 错误处理:网络操作充满不确定性(网络中断、服务器宕机、权限变更等),务必使用 try-catch 块来捕获所有可能的异常(IOException, SocketException 等),并向用户友好的提示。
  4. 被动模式:在 Android 设备上,通常建议使用 ftpClient.enterLocalPassiveMode(),这可以解决很多由于 NAT 和防火墙导致的数据连接无法建立的问题。
  5. 超时设置:为 FTPClient 设置合理的连接超时和默认超时,防止应用在无响应的情况下长时间挂起。
    ftpClient.connectTimeout = 10000 // 10秒连接超时
    ftpClient.defaultTimeout = 60000  // 60秒操作超时
  6. UI 反馈:对于耗时的上传下载操作,一定要给用户反馈,例如显示一个进度条或加载动画,操作完成后显示成功或失败的提示。
  7. 考虑现代替代方案:对于新项目,请认真评估 FTP 是否是最佳选择,现代的 RESTful API 通常更灵活、更安全(HTTPS),并且更适合移动应用,如果必须使用文件传输协议,SFTP (基于 SSH) 也是一个比 FTP/FTPS 更安全的选择,尽管它需要不同的库(如 JSch)。

希望这份详细的指南能帮助你在 Android 应用中成功集成 FTP 功能!

分享:
扫描分享到社交APP
上一篇
下一篇