简介:面向 Android 开发者的 FTP 服务器示例,基于 Apache FtpServer 在移动端搭建可运行的 FTP 服务,适合学习轻量级文件传输、局域网共享与网络服务集成的初中级开发者参考。压缩包共 28 个文件,约 1.72MB,含 11 个 XML 配置与界面描述、7 个 jar 依赖库、5 个 png 图标、1 个 java 源码及 properties 等工程文件,目录按 Android 工程惯例组织,依赖覆盖 FTP 协议、MINA 通信、日志与 slf4j 绑定。已有 593 人学习。读者可了解 Apache FtpServer 在 Android 项目中的集成方式、权限与资源配置、服务启动及文件目录映射的参考实现,并据此复现实验环境、排查集成问题,适合课程设计、功能验证或二次开发。
1. 局域网传文件别再用数据线:android ftp服务器 demo 先把边界讲清
把手机变成局域网 FTP 服务器,这个 android ftp服务器 demo 最值得先看的不是界面,而是服务生命周期和存储边界。它做的事很具体:在 Android 设备上起一个 FTP 服务,把指定目录挂给电脑上的资源管理器或命令行客户端,用来传照片、日志、测试包。ftp服务器 android 场景里,数据线、聊天工具、网盘中转都能省掉,但前提是手机和电脑在同一局域网,且目录权限可控。
它适合两类人:一类要快速验证“手机目录能不能被电脑读写”的开发者,另一类想把固定目录做成临时交换区的新手。别把它当成公网文件服务器,Demo 的默认账号、被动端口和根目录都偏演示性质,直接暴露到公网会把风险放大。
我拆这类 Demo 时先看三件事:服务是否跑在前台、根目录是否落在应用可写目录、被动模式端口有没有固定范围。这三件事决定了你拿到的是能用的传输工具,还是一个只能在本机日志里自嗨的壳子。后面按从服务骨架到验证命令的顺序拆,新手能照着跑,熟手重点看参数和避坑。
2. 拆开 Demo 看服务骨架:Android 上 FTP 控制连接与数据连接怎么落地
Android 上的 FTP 服务器 Demo 看起来只是“开个端口”,实际要同时处理控制连接、数据连接、前台服务通知、存储目录和网络地址。控制连接负责登录、列目录、发命令,数据连接负责真正传文件。手机端最容易翻车的不是登录,而是列目录和上传时数据连接回连的地址不对。Demo 如果只在本机测试过,通常会留下两个隐患:服务绑定在回环地址,或者被动模式返回的地址客户端根本连不上。
这一章先把服务骨架跑起来。不同 Demo 的引擎封装不一样,有的直接把服务端库塞进libs,有的自己写 Socket 解析。你不需要先改引擎源码,先看它暴露了哪些配置项:监听端口、根目录、账号密码、被动端口范围、字符集、只读开关。这六个参数能对上,后面才有排错空间。
2.1 从控制通道到被动模式:FTP 在手机端为什么容易卡在列目录
FTP 和普通 HTTP 不一样,它不是一条连接干到底。客户端先连控制端口,登录成功后,列目录或传文件时会再开一条数据连接。主动模式下由服务端回连客户端,手机在局域网里往往没有固定可达地址,所以 Demo 通常用被动模式:服务端开一组数据端口,把地址和端口告诉客户端,让客户端来连。
问题就出在“告诉客户端”这一步。服务端如果返回127.0.0.1,电脑客户端会去连自己本机,当然列不出目录;如果返回的是容器或虚拟网卡地址,客户端也连不上。常见做法是让服务端绑定0.0.0.0,并在被动模式配置里显式指定对外地址为手机当前局域网 IP,或者至少确保引擎使用连接进来的本地地址。被动端口范围也要固定,比如30000..30010,不要每次随机开一大片,否则日志和排查都很难对上。
另一个容易忽略的点是线程模型。FTP 服务端不能在主线程里阻塞 accept,也不能在onStartCommand里同步等客户端。Demo 里一般会把引擎放到独立线程或线程池,服务只负责生命周期。你拿到工程后先搜start()和stop()的调用位置,确认它们不在 UI 线程,且stop()会释放监听端口。否则第二次启动会报端口占用,表现像“服务没起来”,实际是上一次没停干净。
2.2 把服务跑起来:前台 Service、端口和根目录的最小闭环
Android 新系统对后台服务限制很严,FTP 服务如果只靠普通 Service,切后台或锁屏后很容易被挂起。Demo 里应该用前台 Service,启动时立刻startForeground,通知栏保留一个“FTP 服务运行中”的常驻通知。下面这段是服务骨架,引擎类名按你工程里的实际封装替换,参数含义我在代码后逐项说明。
class FtpForegroundService : Service() { private var engine: FtpServerEngine? = null override fun onCreate() { super.onCreate() // 通知渠道和通知构建放在工程里的 NotificationHelper,Demo 中不要省略 startForeground(NOTIFY_ID, buildNotification("FTP 服务运行中")) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (engine == null) { val root = File(getExternalFilesDir(null), "ftp-root").apply { mkdirs() } val config = FtpConfig( bindHost = "0.0.0.0", port = intent?.getIntExtra("port", 2121) ?: 2121, rootDir = root, userName = intent?.getStringExtra("user") ?: "demo", password = intent?.getStringExtra("pwd") ?: "demo", passivePorts = 30000..30010, charset = "UTF-8", readOnly = intent?.getBooleanExtra("readOnly", true) ?: true ) engine = FtpServerEngine(config).also { it.start() } } return START_STICKY } override fun onDestroy() { engine?.stop() engine = null super.onDestroy() } override fun onBind(intent: Intent?): IBinder? = null }这段代码的逻辑是:服务创建时先进入前台,避免被系统直接回收;onStartCommand里只初始化一次引擎,重复启动不会重复绑定端口;根目录用getExternalFilesDir(null)下的子目录,应用自己有读写权,不依赖全盘存储权限;bindHost用0.0.0.0让局域网网卡都能接入;端口用2121而不是默认21,普通应用绑定低端口通常没有权限;被动端口给一个十来个端口的范围,够小文件传输用,也方便排查;charset固定 UTF-8,减少中文名乱码;readOnly默认 true,先验证只读链路,再开写权限。
Manifest 里至少要有网络权限和前台服务声明。新系统通知权限需要运行时申请,Demo 如果没处理,通知栏不显示但服务可能仍在跑,容易误判。
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <application> <service android:name=".FtpForegroundService" android:exported="false" android:foregroundServiceType="dataSync" /> </application>INTERNET是 Socket 监听和网络访问的基础;ACCESS_NETWORK_STATE和ACCESS_WIFI_STATE用来判断当前网络和取地址;FOREGROUND_SERVICE对应前台服务;POST_NOTIFICATIONS在新系统上不申请会影响通知展示。foregroundServiceType按工程目标填,Demo 里用dataSync比较贴近文件传输场景,但不同目标版本对类型有要求,编译报错时按工程注释调整。
2.3 账号、权限与编码参数:Demo 里最该先改的四个开关
第一开关是账号密码。Demo 默认demo/demo只适合本地验证,不要拿去做长期共享。FTP 的控制连接和很多客户端默认不加密,账号密码在局域网里也是明文级别,所以它只适合可信网络。第二开关是根目录。优先用应用外部文件目录,别一上来就挂整个 SD 卡;新系统分区存储下,全盘路径不是想读就能读。第三开关是被动端口范围。范围太小,多个客户端同时列目录会抢端口;范围太大,日志里不好定位。十到二十个端口对 Demo 足够。第四开关是字符集和只读模式。先把只读打开,确认电脑能列目录、能下载,再关只读测上传。上传失败时不要先怀疑引擎,先看根目录是否可写、客户端是否用了二进制模式。
3. 把手机目录挂给电脑:资源管理器连接、读写验证与中文名处理
服务骨架跑起来后,下一步不是急着改 UI,而是用电脑做一次完整冒烟测试。顺序我一般固定为:拿手机局域网地址、只读登录、列目录、下载一个已知文件、上传一个小文件、再下载回读比对。这个顺序能把“网络不通”“认证失败”“被动端口不通”“写权限不足”“文件损坏”分开,不会一锅粥。
这一章的重点是连接参数和验证方法。FTP 客户端很多,系统文件管理器、命令行工具、轻量客户端都能用,但不同客户端对被动模式、编码、二进制模式的处理不一样。Demo 要做的不是适配所有客户端,而是把标准参数暴露清楚,让常见客户端能连上。
3.1 先拿局域网地址:Wi-Fi 与热点两种网络形态的判定
手机可能连路由器 Wi-Fi,也可能自己开热点给电脑连。两种形态下手机地址不同,但都可以用网卡枚举拿到私有 IPv4 地址。不要只取WifiManager的地址,热点网卡和 Wi-Fi 网卡在不同设备上名称不一样。下面这段枚举所有非回环私有地址,UI 上让用户选一个,日志里也打出来。
fun localIpv4List(): List<String> { return NetworkInterface.getNetworkInterfaces().toList() .flatMap { it.inetAddresses.toList() } .filterIsInstance<Inet4Address>() .filter { !it.isLoopbackAddress && it.isSiteLocalAddress } .mapNotNull { it.hostAddress } .filter { it.isNotBlank() } .distinct() }isLoopbackAddress过滤127.0.0.1,isSiteLocalAddress过滤私有网段地址,hostAddress转成字符串。多网卡设备可能同时返回 Wi-Fi 和热点地址,甚至虚拟网卡地址,所以不要写死取第一个。Demo 的界面可以显示列表,让用户选“当前电脑能 ping 通的那个”。如果列表为空,先检查是否开了飞行模式、是否只连了移动网络。移动网络下没有局域网直连条件,FTP 服务起来也没法让同一局域网电脑访问。
3.2 用系统文件管理器做冒烟测试:登录、列目录、上传、改名四步
命令行测试最干净。把下面命令里的地址换成上一步拿到的手机 IP,端口换成 Demo 配置的端口。先只读列目录,再下载,再上传,最后回读。不要一上来就拖整个文件夹。
# 1. 只读列目录,确认登录和被动模式都通 curl -v --ftp-pasv -u demo:demo ftp://192.168.1.50:2121/ # 2. 下载一个已知文件,确认数据连接能传二进制 curl -v --ftp-pasv -u demo:demo \ ftp://192.168.1.50:2121/readme.txt -o ./readme.downloaded.txt # 3. 上传一个小文件,确认根目录可写 curl -v --ftp-pasv -u demo:demo \ -T ./probe.bin ftp://192.168.1.50:2121/upload/probe.bin # 4. 回读并比对哈希,确认不是零字节或换行被改写 curl -v --ftp-pasv -u demo:demo \ ftp://192.168.1.50:2121/upload/probe.bin -o ./probe.readback.bin sha256sum ./probe.bin ./probe.readback.bin--ftp-pasv强制被动模式,避免客户端尝试主动回连手机;-u指定账号密码;-T是上传;-o是下载到本地;最后用哈希比对,能发现“文件大小看起来对,内容被 ASCII 模式改坏”的情况。如果第一步就失败,看curl -v里连接的是哪个 IP、端口是多少、服务端返回什么。如果登录成功但列目录卡住,重点看被动模式返回的地址和端口。
系统文件管理器测试更接近普通用户:地址栏输入ftp://手机IP:2121,弹窗输入账号密码,能进目录后拖一个文件进去,再拖出来。改名测试也要做一次,因为改名涉及RNFR/RNTO命令,有些 Demo 只实现了下载上传,没实现改名,表现是“能看不能动”。
3.3 中文路径与断点续传:编码、被动端口和临时目录的配合
中文名乱码通常不是单一原因。服务端要固定 UTF-8,客户端也要支持 UTF-8;有的客户端在旧编码下会把中文转成乱码再发命令,服务端收到的是错误字节,落盘文件名自然不对。Demo 能控制的是服务端字符集和路径拼接,不能强迫所有客户端用 UTF-8。我的做法是:根目录下建一个upload英文目录做上传入口,中文文件先放进去测试;如果客户端乱码,先用英文名验证传输链路,再回头处理编码。
断点续传要看引擎是否支持REST和APPE。很多简易 Demo 只支持完整STOR,客户端显示“续传”但实际会覆盖或失败。验证方法是用curl传一个稍大的文件,然后查看服务端日志里有没有REST命令。没有就别在 UI 上承诺断点续传。真要做,可以在服务端写临时文件.part,传完再原子改名;这样客户端中断不会留下半截正式文件。临时目录必须和根目录在同一文件系统,否则改名跨分区会失败。
4. 避坑与排查:Android FTP 服务器 Demo 最常见的五类翻车
这章按“现象 → 原因 → 解决”写,都是我在局域网文件传输里反复遇到的。FTP 的排错不要从代码第一行看起,先分层:网络层能不能到端口,认证层能不能登录,数据连接能不能列目录,文件系统层能不能写,生命周期层会不会被系统杀掉。按层排查比盯着引擎源码快得多。
4.1 连接被拒:端口监听了但电脑连不上
现象:电脑客户端提示连接被拒绝或超时,手机日志里服务显示已启动。原因常见有四种:服务绑定在127.0.0.1而不是0.0.0.0;端口小于 1024 普通应用绑定失败;手机和电脑不在同一网段;服务启动在错误网卡或启动后马上被回收。解决:日志里打印实际绑定地址和端口;端口用2121这类高位端口;用手机浏览器访问同一个 IP 的其他服务确认局域网可达;前台服务通知必须存在。还要注意有些设备开了热点后,Wi-Fi 网卡地址和热点地址同时存在,你给电脑的地址必须是电脑能 ping 通的那个。
4.2 能登录不能列目录:被动模式端口没放行
现象:账号密码能过,客户端卡在“列出目录”或提示数据连接失败。原因通常是被动模式返回的地址不对,或者被动端口范围没有监听。服务端告诉客户端“来连这个地址和端口”,客户端照着连,连不上就列不出目录。解决:看日志里227响应里的 IP 和端口;如果 IP 是127.0.0.1,改成手机局域网地址;如果端口不在你配置的范围,检查引擎的被动端口配置是否生效;如果客户端在电脑防火墙后,换成命令行curl --ftp-pasv排除客户端问题。被动端口范围不要太大,十到二十个足够 Demo 用。
4.3 上传成功文件却损坏:二进制模式与存储权限的暗坑
现象:客户端显示上传完成,文件大小也差不多,但打不开或哈希不一致。原因一是客户端用了 ASCII 模式,换行被转换;原因二是根目录在不可随机写的路径上,写进去的是空文件或截断文件;原因三是存储权限只给了读,写操作静默失败。解决:上传测试强制二进制模式;根目录优先用应用外部文件目录;上传后用sha256sum比对;日志里记录写入字节数和目标绝对路径。路径不要直接拼用户输入,先做规范化校验,防止..跳出根目录。
fun resolveInsideRoot(root: File, remotePath: String): File { val rootCanonical = root.canonicalFile val target = File(rootCanonical, remotePath).canonicalFile val prefix = rootCanonical.path + File.separator require(target.path == rootCanonical.path || target.path.startsWith(prefix)) { "path escape: $remotePath" } return target }canonicalFile会把..和符号链接展开,再判断目标是否仍在根目录下。require不通过就直接拒绝命令。这个校验要放在每个文件操作入口,不要只放在上传。删除、改名、建目录同样需要。
4.4 切后台就断:Service 生命周期与电池策略
现象:手机亮屏时传得好好的,锁屏或切到别的应用几分钟后连接断开。原因是服务没有前台化,或者系统电池策略把网络挂起。解决:用前台 Service 并保留常驻通知;不要在onStartCommand里返回后立刻stopSelf;onDestroy里正确stop()引擎释放端口;测试时把电池优化对当前应用设为不受限。还要注意有些设备在锁屏后限制后台网络,传输大文件时保持屏幕常亮或至少插电测试,不能只测十秒。
4.5 中文名乱码或覆盖:编码、大小写和路径穿越校验
现象:中文文件名变成问号或乱码;同名文件上传后互相覆盖;删除目录时把根目录外的东西也动了。原因分别是客户端和服务端编码不一致、文件系统大小写不敏感、路径没有规范化。解决:服务端统一 UTF-8;日志打印收到的原始文件名和落盘路径;上传前检查同名文件,按需生成name(1).ext;所有路径走resolveInsideRoot;只读模式下在命令层拦截写命令,不要只靠文件系统权限。公网绝对不要开匿名写,局域网也要默认账号密码。
5. 把 Demo 改成能交付的小工具:配置持久化、日志与安全边界
Demo 能跑通不等于能交付。真正拿给同事或测试同学用,至少要有配置持久化、可读日志和基本安全边界。否则每次重启都要重新输端口、账号、根目录,出问题只能靠猜。这个改造不需要重写引擎,重点是把启动参数从代码里搬到配置层,把运行状态从通知栏搬到日志页,把危险操作挡在入口。
5.1 配置别写死:端口、根目录、账号的持久化模型
端口、根目录、账号、被动端口范围、只读开关都应该可保存。Demo 里用轻量键值存储就够,注意密码不要当普通配置明文长期保存;如果只是本地演示,至少提示用户不要复用重要密码。下面是一个配置模型和读写封装,字段和你的引擎配置一一对应。
data class FtpUiConfig( val port: Int = 2121, val rootPath: String = "", val userName: String = "demo", val password: String = "demo", val passiveStart: Int = 30000, val passiveEnd: Int = 30010, val readOnly: Boolean = true ) class FtpConfigStore(context: Context) { private val sp = context.getSharedPreferences("ftp_demo_config", Context.MODE_PRIVATE) fun load(): FtpUiConfig = FtpUiConfig( port = sp.getInt("port", 2121), rootPath = sp.getString("root", "") ?: "", userName = sp.getString("user", "demo") ?: "demo", password = sp.getString("pwd", "demo") ?: "demo", passiveStart = sp.getInt("passive_start", 30000), passiveEnd = sp.getInt("passive_end", 30010), readOnly = sp.getBoolean("read_only", true) ) fun save(config: FtpUiConfig) { sp.edit() .putInt("port", config.port) .putString("root", config.rootPath) .putString("user", config.userName) .putString("pwd", config.password) .putInt("passive_start", config.passiveStart) .putInt("passive_end", config.passiveEnd) .putBoolean("read_only", config.readOnly) .apply() } }port建议限制在1024..65535,避开系统端口;rootPath保存前先canonicalFile,不存在就创建;passiveStart到passiveEnd的跨度不要超过几十个,端口太多会干扰日志;readOnly默认 true。密码字段只适合 Demo,长期使用要换成临时密码或每次启动随机密码,并提示用户不要在公共网络开写权限。
5.2 日志与状态回传:让“连不上”有迹可循
FTP 排错最怕只有一句“连接失败”。日志至少记录:启动时的绑定地址和端口、客户端 IP、登录结果、每条命令名、被动模式返回的地址端口、文件操作的目标路径和字节数。不要记录密码。日志用环形缓冲,避免长时间运行吃内存。
class FtpEventLogger(private val maxLines: Int = 200) { private val lines = ArrayDeque<String>() fun log(event: String) { lines.addLast("${System.currentTimeMillis()} $event") while (lines.size > maxLines) { lines.removeFirst() } } fun dump(): String = lines.joinToString("\n") }maxLines控制内存占用,System.currentTimeMillis()方便和电脑端时间对齐。日志页提供一个“复制全部”按钮,但不要自动上传。状态回传可以用StateFlow或LiveData把“未启动 / 监听中 / 有客户端连接 / 错误”传给界面。错误状态要带原因,比如“端口绑定失败”“根目录不可写”“被动端口范围无效”,这比只显示红色圆点有用。
5.3 安全边界:只读模式、路径穿越拦截与局域网白名单
只读模式要在命令层做,不要只靠文件系统权限。上传、删除、改名、建目录这些命令入口统一检查readOnly,命中就返回权限错误。路径穿越用前面的resolveInsideRoot,每个入口都调用。局域网白名单可以做轻量判断:只允许私有地址客户端连接,但这不等于安全,公共 Wi-Fi 下同一网段的人也能连。更稳的做法是默认随机密码、默认只读、默认关闭匿名登录,并在界面上明确提示“仅在可信局域网使用”。
fun isPrivateClient(ip: String): Boolean { val address = InetAddress.getByName(ip) return address.isSiteLocalAddress || address.isLoopbackAddress }isSiteLocalAddress能过滤常见私有网段,isLoopbackAddress放行本机调试。这个判断只能挡住明显的外部地址,不能替代认证。真正要共享给固定几台电脑,可以在启动时生成一次性密码,传输完就停服务。别把 Demo 的默认端口和默认账号一起暴露在公共网络,这是最常见的血泪经验。
6. 交付前跑一遍验证矩阵:Android FTP 服务器 Demo 的三组命令与两张表
最后一轮不要靠“我感觉能连”收尾。我一般用三组命令加两张表做回归:只读登录、被动列目录、上传回读。第一组确认认证和控制连接,第二组确认数据连接,第三组确认写权限和二进制完整性。任何一组失败,都回到对应章节查,不要跳步。
| 验证项 | 命令要点 | 通过标准 | 常见失败 |
|---|---|---|---|
| 只读登录 | curl -v --ftp-pasv -u user:pass ftp://IP:PORT/ | 返回目录列表 | 端口不通、账号错 |
| 下载回读 | curl ... /readme.txt -o out | 文件哈希一致 | 被动端口不通、ASCII 转换 |
| 上传回读 | curl -T probe.bin .../upload/probe.bin | 上传后哈希一致 | 根目录不可写、只读未关 |
| 中文名 | 上传测试.txt再列目录 | 名称不乱码 | 客户端编码不一致 |
| 生命周期 | 锁屏三分钟后重连 | 仍能列目录 | 服务未前台化 |
# 只读登录与列目录 curl -v --ftp-pasv -u demo:demo ftp://192.168.1.50:2121/ # 上传后立刻回读,比对内容而不是只看大小 curl -v --ftp-pasv -u demo:demo -T ./probe.bin \ ftp://192.168.1.50:2121/upload/probe.bin curl -v --ftp-pasv -u demo:demo \ ftp://192.168.1.50:2121/upload/probe.bin -o ./probe.back.bin sha256sum ./probe.bin ./probe.back.bin命令里的 IP、端口、账号都换成你当前配置;--ftp-pasv必须保留,除非你明确知道主动模式在这个网络里可用;-T上传前先确认只读开关已关闭。哈希一致才算通过,大小一致不算。中文名测试单独做一次,别和上传混在一起,否则乱码和写失败会互相干扰。
从那以后我每次改端口、根目录或被动端口范围,都强制走一遍只读登录、被动列目录、上传回读这三步,再锁屏三分钟重连一次。要复现这份 Android FTP 服务器 Demo,建议直接拿工程包对照这个最小闭环,先跑只读,再开写权限,把默认账号和默认端口改掉再交付。希望帮到你。
本文还有配套的精品资源,点击获取