news 2026/10/26 10:50:06

Android FTP服务器Demo环境配置与被动模式排错指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android FTP服务器Demo环境配置与被动模式排错指南

简介:这份 Android FTP 服务器 Demo 面向希望在手机端搭建轻量级 FTP 服务的 Android 开发者,以及学习物联网、局域网文件传输的读者,核心基于 Apache FtpServer,演示在 Android 应用中集成 FTP 服务并完成文件上传下载的基本思路。压缩包共 28 个文件,约 1.72MB,包含 7 个依赖 jar(如 ftpserver-core、mina-core、commons-net-ftp、log4j、slf4j 等)、11 个 XML 配置与界面资源、5 个 PNG 图标、1 个 Java 入口文件及 1 个 properties 配置,整体为可导入的 Eclipse 工程结构。已有 593 人学习,说明该示例对入门 FTP 服务端集成具有一定参考价值。读者可据此了解 Apache FtpServer 与 MINA、日志组件的依赖搭配,查看 AndroidManifest、res 布局和 libs 组织方式,并以此为骨架改造端口、用户权限、目录映射与文件传输流程,适合作为局域网文件共享、设备间传输等场景的原型参考。

1. 从一台旧手机说起:android ftp服务器 demo 跑不起来,多半是环境没对齐

前阵子帮某实验室把一台退役的旧手机改成局域网文件中转站,需求很朴素——PC 和手机不插线就能互传文件夹。手边只有一个 android ftp服务器 demo 工程,看着代码不多,编译也过了,结果装到机器上就是连不上:服务端日志显示已经在 2121 端口监听,PC 端 FTP 客户端一直报425 Can't open data connection,换了台电脑、换了根数据线,情况一模一样。

这类 demo 最容易让人误判。它不像装个 App 那样点两下就完事,背后牵着四样东西:JDK 与 AGP 的版本对不齐、ftpserver-core的 slf4j 依赖没换、清单里的权限少声明一个、被动模式端口没放行。少一样都表现为“连不上”,但根因完全不同,光看日志根本看不出是哪一样。下面的内容就是把这四样按安装顺序摆开,从选版本、拉依赖、改清单,到起服务、调参数、排查、验证,一条线走完。适合手上有一套安卓工程、准备把手机当 FTP 服务器用的人,也适合被这个 demo 坑过一次、想搞清楚到底哪一步错了的人。

2. 环境清单:Android FTP 服务器 Demo 要装的四样东西和版本对齐

动手之前先把版本钉死。Android 上的 FTP 服务端不是系统能力,全靠第三方库在应用进程里起一个 Socket 监听,所以它对工具链版本比普通 App 敏感得多。版本一旦飘,后面看到的报错会非常离谱——比如编译期提示Duplicate class,运行期提示NoClassDefFoundError,但都跟 FTP 逻辑本身没关系。

2.1 JDK、Gradle、SDK 三者的版本对齐

我一般按“AGP 定 JDK、JDK 定 Gradle”的顺序反推。ftpserver-core1.2.0 是 Java 8 字节码,AGP 8.x 要求 JDK 17 编译,所以 JDK 直接上 17,不要图省事用 JDK 11,否则 AGP 8 会直接拒绝构建。

组件我固定用的版本说明
JDK17AGP 8.x 最低要求,低于此版本构建直接失败
Gradle8.7与 AGP 8.5 配套,不要用 9.x 尝鲜
AGP8.5.0支持packaging {}块,便于排除 META-INF 冲突
compileSdk / targetSdk34前台服务类型必须在这一档声明
minSdk21ftpserver-core依赖的 MINA 2.x 在 21 以下容易出 NIO 兼容问题

minSdk这一项值得多说一句。有的 demo 为了兼容老设备会写 16 或 19,编译能过,但在低版本机器上一旦有客户端连接,MINA 的NioSocketAcceptor可能抛UnsupportedOperationException,因为早期 Android 的 NIO 实现并不完整。我踩过一次,日志里只有一行java.lang.UnsupportedOperationException: None of the selectors are interested,查了半天才发现是 minSdk 写低了。21 是这套组合下最省心的下限。

2.2 ftpserver-core 1.2.0 与 slf4j 的替换引入

依赖这一块是整个安装过程里最容易翻车的地方。org.apache.ftpserver:ftpserver-core:1.2.0会传递引入slf4j-api,但它只是接口,没有绑定实现。在纯 JVM 项目里你随便加一个slf4j-simple就完事,可在 Android 上,slf4j-simple会往 stdout 写,日志全被吞掉,而且启动时会打印Failed to load class "org.slf4j.impl.StaticLoggerBinder"。正确做法是把slf4j-api排掉,换成slf4j-android。

// app/build.gradle.kts android { namespace = "com.example.ftpdemo" compileSdk = 34 defaultConfig { minSdk = 21 targetSdk = 34 multiDexEnabled = true // 方法数超 64K 时打开,ftpserver 的传递依赖不算小 } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true // 库里有 Java 8 时间 API 的调用 } packaging { // 这几个文件在多个依赖里重复出现,不排掉会报 Duplicate class resources.excludes += setOf( "META-INF/DEPENDENCIES", "META-INF/LICENSE", "META-INF/LICENSE.txt", "META-INF/NOTICE", "META-INF/NOTICE.txt" ) } } dependencies { implementation("org.apache.ftpserver:ftpserver-core:1.2.0") { // 关键一步:排掉自带的 slf4j-api,避免和下面显式引入的版本打架 exclude(group = "org.slf4j", module = "slf4j-api") } implementation("org.slf4j:slf4j-api:1.7.36") implementation("org.slf4j:slf4j-android:1.7.36") // 把日志导到 Logcat coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") }

这段配置里有三处逻辑要说清楚。第一处是exclude:ftpserver-core传递进来的slf4j-api版本可能和你想用的不一致,不排掉的话 Gradle 会做版本仲裁,仲裁结果未必是你想要的,尤其是多模块工程里,仲裁后slf4j-android可能被降级到不兼容的版本。第二处是packaging.resources.excludes:LICENSE、NOTICE 这类文件在mina-core、ftpserver-core、slf4j里各有一份,AGP 8 默认开启资源重复检查,不排就直接构建失败。第三处是 desugaring:ftpserver-core内部用到了 Java 8 的java.time相关调用,minSdk 21 的设备上没有这些类,必须靠脱糖补上。

提示:排掉slf4j-api之后一定要显式引入,别指望slf4j-android自己带进来,它只提供绑定实现,不提供接口。

2.3 AndroidManifest 权限与前台服务:按 API 级别分档声明

清单文件这块,很多 demo 只写了INTERNET,装上之后发现客户端连上来一片空白,或者跑几分钟就自动断开,问题都出在这里。FTP 服务要读外部存储、要在后台常驻、要在 Android 13 以上弹通知,这三件事对应三组不同 API 级别的权限。

<!-- AndroidManifest.xml --> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <!-- 本地网络通信,虽然是局域网,INTERNET 权限仍然必须声明 --> <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" /> <!-- 前台服务:Android 9 起必须有,14 起还要求声明类型 --> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <!-- Android 13 起通知需要运行时授权,不申请则前台服务通知不显示 --> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- Android 12 及以下走传统存储权限 --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" /> <!-- Android 11 起要访问共享目录只能走「所有文件访问」,上架需说明用途 --> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> <application android:label="FTP Demo" android:requestLegacyExternalStorage="true"> <service android:name=".FtpForegroundService" android:exported="false" android:foregroundServiceType="dataSync" /> </application> </manifest>

foregroundServiceType这一项是 Android 14 的硬性要求,不写会在启动前台服务时抛MissingForegroundServiceTypeException,而且异常信息不会直接告诉你是这个原因。选dataSync是因为 FTP 传输本质是数据同步;如果你的场景是短时间传文件,也有人在用shortService,但那个类型有总时长限制,传大文件会被系统掐断,不建议。

requestLegacyExternalStorage只对 Android 10 有效,Android 11 之后系统直接忽略它,所以不能指望这一行解决所有存储问题。

API 级别额外要处理的事
21–22运行时申请存储权限即可
23–28运行时权限 + 前台服务
29加requestLegacyExternalStorage
30–32改申请MANAGE_EXTERNAL_STORAGE,或改用应用私有目录
33运行时申请POST_NOTIFICATIONS
34前台服务必须声明foregroundServiceType

3. 把服务跑起来:FtpServerFactory 的最小可用配置

环境齐了,接下来是把它真正跑起来。这一段代码不长,但每一行都对应一个后面会出问题的参数,我习惯一次性写全,省得回头补。

3.1 十分钟能跑通的一段初始化代码

public final class FtpServerHolder { private static FtpServer server; public static synchronized boolean start(Context ctx, String userName, String password, File rootDir) throws FtpException { // 已经在跑就不重复启动,否则第二次绑定端口会抛 BindException if (server != null && !server.isStopped()) { return true; } FtpServerFactory factory = new FtpServerFactory(); // ---------- 1. 监听器 ---------- ListenerFactory listenerFactory = new ListenerFactory(); listenerFactory.setPort(2121); // 不用 21,原因见 4.1 listenerFactory.setServerAddress("0.0.0.0"); listenerFactory.setIdleTimeout(300); // 单位秒,空闲 5 分钟断开 // ---------- 2. 被动模式端口池 ---------- DataConnectionConfigurationFactory dcc = new DataConnectionConfigurationFactory(); dcc.setPassivePorts("50000-50010"); // 范围要 >= 最大并发数 dcc.setPassiveAddress(getLocalIp(ctx)); // 填手机在局域网里的 IP listenerFactory.setDataConnectionConfiguration(dcc.createDataConnectionConfiguration()); factory.addListener("default", listenerFactory.createListener()); // ---------- 3. 用户 ---------- PropertiesUserManagerFactory umf = new PropertiesUserManagerFactory(); umf.setFile(new File(ctx.getFilesDir(), "users.properties")); UserManager userManager = umf.createUserManager(); BaseUser user = new BaseUser(); user.setName(userName); user.setPassword(password); user.setHomeDirectory(rootDir.getAbsolutePath()); user.setEnabled(true); user.setMaxIdleTime(300); user.setAuthorities(Arrays.asList( new WritePermission(), // 允许上传、删除、重命名 new ConcurrentLoginPermission(3, 3) // 最多 3 个并发,每个账号 3 个 )); userManager.save(user); factory.setUserManager(userManager); // ---------- 4. 文件系统 ---------- NativeFileSystemFactory fsFactory = new NativeFileSystemFactory(); fsFactory.setCreateHomeDirectory(true); // 目录不存在时自动建 factory.setFileSystem(fsFactory); server = factory.createServer(); server.start(); return true; } public static synchronized void stop() { if (server != null && !server.isStopped()) { server.stop(); // 必须调用,否则端口不会释放 } server = null; } private static String getLocalIp(Context ctx) { // 从当前活动网络取链路地址,比 WifiManager 更可靠 ConnectivityManager cm = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE); LinkProperties lp = cm.getLinkProperties(cm.getActiveNetwork()); if (lp == null) return "127.0.0.1"; for (LinkAddress addr : lp.getLinkAddresses()) { InetAddress ip = addr.getAddress(); if (ip instanceof Inet4Address && !ip.isLoopbackAddress()) { return ip.getHostAddress(); } } return "127.0.0.1"; } }

逐段说明。setPort(2121)是监听端口,客户端连接时填的就是这个数字,改成别的值客户端也得跟着改,不是内部参数。setServerAddress("0.0.0.0")表示监听全部网卡,包括 Wi-Fi 和蜂窝;如果想只在 Wi-Fi 上暴露,可以换成getLocalIp()的返回值,但 Wi-Fi 一旦重连 IP 会变,监听器得重建,实际项目里更常见的是绑0.0.0.0然后用系统防火墙或网络策略控制。

setIdleTimeout(300)和user.setMaxIdleTime(300)是两个不同的超时:前者是连接级空闲,超过 5 分钟没有任何命令就断开;后者是用户级,同一个账号闲置太久会被踢掉。两个都设成 300 是保守取值,传大文件时客户端会持续发命令,不会误断。

setPassivePorts("50000-50010")是最容易被忽略的一行。FTP 的数据连接方向和命令连接方向是反的,客户端连服务端的 2121 建控制通道,服务端再回头连客户端的一个端口建数据通道。如果手机在 NAT 后面,被动模式需要服务端告诉客户端“你来连我这个端口”,这个端口就是 50000–50010 范围内随机挑的。不配也行,库会随机挑一个高位端口,但随机端口没法在路由器上预放行,跨网段就必然失败。

getLocalIp()用ConnectivityManager而不是WifiManager.getConnectionInfo().getIpAddress(),后者在 Android 10 之后返回的是个不可用的值,而且不支持多网络。LinkProperties里取第一个非回环的 IPv4 地址,这是目前最稳的写法。

3.2 用户、目录与权限:PropertiesUserManager 逐项拆解

BaseUser上有十几个 setter,常用的其实就五个,另外几个配错了会有反效果。

参数取值示例作用与坑点
setNamedemo登录用户名,区分大小写
setPassworddemo123明文存储,users.properties里能看到,别用真实密码
setHomeDirectory/sdcard/ftproot用户登录后的根目录,必须是可读可写且已授权的路径
setEnabledtrue设为false后账号仍在但登录直接被拒,适合临时停用
setMaxIdleTime300单位秒,设 0 表示不限,不建议
setAuthorities见下权限集合,缺WritePermission就只能读

setAuthorities里可以塞的权限类型有WritePermission(写、删、改名)、ConcurrentLoginPermission(max, perUser)、TransferRatePermission(upload, download)等。只给WritePermission不给ConcurrentLoginPermission的话,默认并发上限很低,多个客户端同时连会互相挤掉。TransferRatePermission的单位是字节每秒,想限速到 1MB/s 就传1024 * 1024,传0表示不限。

PropertiesUserManagerFactory.setFile()指定的是用户配置文件的落盘位置。放在ctx.getFilesDir()下比较稳妥,卸载应用会一起清掉;放在外部存储目录的话,用户可以手动改文件绕开密码,demo 场景无所谓,真要给别人用建议换个实现。

注意:homeDirectory指向/sdcard根目录时,Android 11 以上即使给了MANAGE_EXTERNAL_STORAGE,File.listFiles()也可能返回空数组。稳妥做法是把根目录设成getExternalFilesDir(null),这个路径不需要任何额外权限。

3.3 前台服务托管:让 FTP 进程不被系统回收

服务在 Activity 里起,切到后台几分钟就被系统杀掉,这是 Android 上跑常驻服务的通病。解决办法是把它托管到前台服务里,配一个常驻通知,系统就不会因为内存压力优先回收它。

public class FtpForegroundService extends Service { private static final String CHANNEL_ID = "ftp_server"; private static final int NOTIFY_ID = 1001; @Override public void onCreate() { super.onCreate(); // Android 8 起通知必须挂在渠道上,否则不显示 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel channel = new NotificationChannel( CHANNEL_ID, "FTP 服务", NotificationManager.IMPORTANCE_LOW); channel.setShowBadge(false); getSystemService(NotificationManager.class).createNotificationChannel(channel); } } @Override public int onStartCommand(Intent intent, int flags, int startId) { Notification notification = new Notification.Builder(this, CHANNEL_ID) .setContentTitle("FTP 服务运行中") .setContentText("端口 2121,局域网可访问") .setSmallIcon(android.R.drawable.stat_sys_upload) .setOngoing(true) .build(); // Android 10 起 startForeground 要带类型,否则会抛异常 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground(NOTIFY_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); } else { startForeground(NOTIFY_ID, notification); } try { FtpServerHolder.start(this, "demo", "demo123", new File(getExternalFilesDir(null), "ftproot")); } catch (FtpException e) { // 启动失败要显式 stopForeground,否则通知会一直挂着 stopForeground(true); stopSelf(); } return START_STICKY; // 被系统杀掉后尝试重建 } @Override public void onDestroy() { FtpServerHolder.stop(); // 服务销毁必须释放端口 super.onDestroy(); } @Override public IBinder onBind(Intent intent) { return null; } }

START_STICKY值得单独说一句。它表示服务被系统杀掉后会自动重建,但重建时Intent是null,所以onStartCommand里不能直接读intent.getExtras(),否则空指针。很多人在这里翻车,表现为“用着用着服务起来了但立刻崩”。

onDestroy里的stop()不能省。前台服务被用户在设置里强制停止时,onDestroy会调用,如果不释放FtpServer,端口会一直被占着,下次启动直接BindException: Address already in use。这个坑在小范围测试时特别难发现,因为卸载重装才能恢复。

4. 参数取舍:端口、编码、超时这三处最容易配错

代码能跑通只是第一步,真正决定这套 FTP 服务器好不好用的,是几个参数怎么定。这几个参数没有绝对正确的值,只有适配场景的值。

4.1 端口与绑定地址:为什么我固定用 2121

FTP 的标准控制端口是 21,但 Android 上普通应用没有权限绑定 1024 以下的端口,直接设 21 会在server.start()时抛Permission denied。有人会想到用 root 提权,这是得不偿失的做法,因为一旦要 root,这个 demo 就失去了通用性。

2121 是社区里最常用的替代端口,原因是它在很多 FTP 客户端的默认端口列表里排第二,输入 IP 之后客户端有时会自动尝试。除此之外,改端口的另一个隐藏收益是避开运营商的端口扫描,某些公共 Wi-Fi 对 21 端口有特殊处理,2121 一般不会触发。

绑定地址的选择分两种情况。绑0.0.0.0最省事,Wi-Fi、热点、USB 网络共享全部能连,代价是同一台设备上的其他应用也能访问。绑具体 IP 更安全,但必须监听网络变化,在ConnectivityManager.NetworkCallback的onLinkPropertiesChanged里重建监听器,代码量翻倍。我的做法是默认绑0.0.0.0,要更严格时在应用层加一个 IP 白名单,只允许指定网段连接,比反复重建监听器稳定得多。

还有一个细节:如果你同时开着 Wi-Fi 和蜂窝,getLinkProperties(getActiveNetwork())返回的可能是蜂窝网络的地址,客户端从局域网根本连不上。稳妥写法是遍历cm.getAllNetworks(),挑TRANSPORT_WIFI的那一个。

4.2 传输模式与中文文件名:二进制模式别乱改

FTP 协议有两种传输模式:ASCII 和二进制。ASCII 模式会对文件内容做换行符转换,把\n转成\r\n。传文本文件没问题,传 APK、图片、压缩包会直接损坏——文件大小对不上,装上去提示“解析包错误”。服务端一般不用管,客户端连接时默认就是二进制;但有些老客户端或者脚本化的ftp命令默认走 ASCII,需要手动敲binary。

中文文件名是另一个高频问题。Android 的文件系统本身是 UTF-8,NativeFileSystemFactory直接用java.io.File读目录,服务端这一侧不存在编码问题。乱码基本都出在客户端:Windows 资源管理器自带的 FTP 客户端默认按 GBK 解析,看到 UTF-8 的中文名就是一堆问号。解决办法不是改服务端,而是换客户端——FileZilla 在站点管理器里把字符集设成“强制 UTF-8”就正常了。

服务端能做的是主动发一条OPTS UTF8 ON的响应,告诉客户端“我支持 UTF-8”。这一条在ftpserver-core里默认是开的,不需要额外配置。真遇到某些客户端不认,也不要试图去改服务端的默认编码,改了之后反而会让原本正常的客户端乱码,得不偿失。

4.3 空闲超时、并发数与被动端口范围

这三个参数是一组,配的时候要一起看。

空闲超时setIdleTimeout(300)设太小,用户浏览目录慢一点就被断开,体验很差;设太大,连接一直挂着占资源。300 秒是经验值,传大文件时客户端会持续发命令刷新计时,不会误断。

并发数由ConcurrentLoginPermission(max, perUser)控制,第一个参数是全局最大并发登录数,第二个是单账号最大并发。这两个数字直接影响被动端口池的大小——如果全局并发设成 10,被动端口范围只给 3 个(比如 50000-50002),第 4 个客户端连接时就会失败,报425。常见做法是让端口池大小至少等于全局并发数,宽裕一点给到 1.5 倍。

被动端口范围还受路由器 NAT 表影响。范围太大会占用过多 NAT 条目,太小又不够用,50000-50010 这 11 个端口覆盖 10 个并发是够的。如果手机是通过热点共享网络,热点的 NAT 表容量通常比家用路由器小,并发数建议压到 5 以内。

限速用TransferRatePermission(uploadBytesPerSec, downloadBytesPerSec)。传 0 表示不限速。局域网场景一般不需要限速,但在共享带宽的环境里,给个 2MB/s 的上限能避免一个客户端把整条链路吃满。

5. 排查现场:连不上、连上读不出、读出乱码的五种情况

下面这五条是我在实际部署里反复遇到的,每一条都按“现象—原因—解决”记录,顺序按出现频率排。

5.1 客户端报 425,日志里能看到登录成功

现象:FileZilla 输入 IP 和账号密码,能连上、能通过认证,一到列目录就卡住,最后报425 Can't open data connection。服务端日志里能看到USER、PASS、PWD都正常返回。

原因:被动模式的数据连接没有建立起来。要么是setPassiveAddress填错了(填成了127.0.0.1或者蜂窝网络的地址),要么是手机和 PC 不在同一个网段,要么是路由器开启了 AP 隔离,同一 Wi-Fi 下的设备互相不可见。

解决:先确认getLocalIp()返回的是不是手机在 Wi-Fi 下的真实地址,用ipconfig或ifconfig对一下网段是否一致。如果地址没问题,去路由器后台关掉“AP 隔离”或“无线隔离”。第三检查手机是不是连着蜂窝同时开着 Wi-Fi,某些机型会优先走蜂窝,导致被动地址取错——这种情况改成遍历getAllNetworks()挑 Wi-Fi 网络即可。

5.2 登录成功但目录是空的

现象:认证通过,LIST命令返回 200,但客户端里一个文件都看不到,服务端日志没有任何异常。

原因:homeDirectory指向的路径应用没有读权限。Android 11 之后,即使声明了MANAGE_EXTERNAL_STORAGE,也要在系统设置里手动授予“所有文件访问”权限,仅靠运行时申请是不够的。另一种可能是homeDirectory指向了一个不存在的目录,NativeFileSystemFactory在setCreateHomeDirectory(false)时不会自动创建,listFiles()返回null,被当成空目录处理。

解决:把根目录换成getExternalFilesDir(null),这个路径属于应用私有目录,不需要任何额外权限,是所有方案里最省事的。如果必须要访问共享目录,引导用户去“设置 → 应用 → 特殊权限 → 所有文件访问”手动打开,并在代码里用Environment.isExternalStorageManager()判断状态。

5.3 启动即崩溃,日志里有 StaticLoggerBinder

现象:点“启动服务”按钮,应用直接闪退,Logcat 里能看到Failed to load class "org.slf4j.impl.StaticLoggerBinder"或者NoClassDefFoundError: org/slf4j/LoggerFactory。

原因:slf4j-api在 classpath 里但没有绑定实现,或者绑定实现被前面的exclude一起排掉了。这个错误不会在编译期暴露,只在运行期第一次调用LoggerFactory.getLogger()时抛。

解决:检查build.gradle的依赖树,执行./gradlew :app:dependencies --configuration debugRuntimeClasspath,确认slf4j-android确实在最终产物里。如果被别的库的exclude规则连带排掉了,在dependencies块末尾再显式加一次。

5.4 锁屏几分钟后连接断开

现象:白天用得好好的,手机锁屏放一会儿,再连就提示连接超时,解锁后重新连又正常。

原因:没有前台服务,或者前台服务启动了但电池优化没关。Android 的 Doze 模式在设备静止时会挂起后台网络和 CPU,FTP 监听线程跟着一起被冻结。

解决:先把服务托管到前台服务(见 3.3),再引导用户把应用加入电池优化白名单,代码里用PowerManager.isIgnoringBatteryOptimizations()判断,没加的话跳转到ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS。这一步在很多国产 ROM 上还要额外在“自启动管理”里放行,这部分没有统一 API,只能在启动时给个引导提示。

5.5 中文文件名乱码或下载后打不开

现象:目录能列出来,但中文文件名显示成问号或方块;或者 APK 下载下来提示“解析包错误”。

原因:前者是客户端字符集问题,后者是传输模式走了 ASCII。

解决:乱码换 FileZilla 并把字符集强制设为 UTF-8,不要在服务端改编码。文件损坏在客户端执行一次binary命令切换到二进制模式,或者在 FileZilla 的传输设置里把“传输类型”改成“二进制”。这两条看起来简单,但因为现象跟“服务端配置错误”高度相似,实际排查时经常绕远路。

6. 装完怎么验证:三种客户端交叉确认才算跑通

服务起来、客户端能连上,还不能算装完。FTP 这个协议在不同客户端上的行为差异很大,单一客户端验证通过,换一台机器可能就挂。我习惯用三种客户端交叉确认,三种都过才算真的跑通。

第一层用命令行,排除 GUI 客户端的干扰。在 PC 上开终端:

# 只看认证和目录,-v 打详细过程,方便定位卡在哪一步 curl -v "ftp://192.168.1.20:2121/" --user demo:demo123 # 上传一个 1MB 的测试文件,验证写权限和二进制模式 curl -T ./test-1mb.bin "ftp://192.168.1.20:2121/" --user demo:demo123 # 下载回来,和原文件比对 md5,验证内容没有被换行转换污染 curl -o ./test-back.bin "ftp://192.168.1.20:2121/test-1mb.bin" --user demo:demo123 md5sum ./test-1mb.bin ./test-back.bin

-v会打印出227 Entering Passive Mode这一行,后面括号里的六个数字就是被动模式的 IP 和端口,把它换算出来和setPassiveAddress、setPassivePorts对一下,能立刻发现端口池没生效或者 IP 填错的问题。md5 一致说明传输模式是二进制,不一致基本可以断定走了 ASCII。

第二层用 GUI 客户端,模拟真实使用。FileZilla 连上去之后,重点看三件事:中文文件名显示是否正常、拖拽上传是否成功、断线重连能不能自动恢复。GUI 客户端会自动处理很多协议细节,它跑通了说明普通用户能用。

第三层用手机上的文件管理器,验证本机回环。很多手机自带的文件管理器支持添加 FTP 站点,填127.0.0.1:2121,如果本机能连上、PC 连不上,问题一定在网络层而不是服务端代码。

验证项通过标准不通过的典型原因
认证230 Login successful账号密码错、users.properties未落盘
列目录返回文件列表且中文正常存储权限缺失、客户端字符集
上传返回226 Transfer complete缺WritePermission、目录只读
下载校验md5 与原文件一致走了 ASCII 模式
断线重连30 秒内能重新连上端口未释放、服务被系统回收

验证顺序也有讲究,我一般先跑 md5 那条,因为它是唯一一个能同时暴露“权限、端口、传输模式”三个问题的检查。上传下载都通了,再去调中文文件名和断线重连这些体验层面的问题,效率高很多。

从那以后我每次起 FTP 服务,都强制走一遍“列目录 → 传 1MB 文件 → 下载回来比 md5”这条链路,再去看别的现象。这条链路十分钟能跑完,但能挡掉八成的返工。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 10:49:58

深入解析Node.js事件循环与异步I/O原理

1. Node.js 事件循环机制再探 在上一篇文章中&#xff0c;我们初步探讨了Node.js事件循环的基本概念。今天我们将深入这个核心机制&#xff0c;理解它如何支撑Node.js的高性能异步I/O模型。 Node.js的事件循环不是简单的队列处理系统&#xff0c;而是一个精心设计的六阶段处理…

作者头像 李华
网站建设 2026/10/26 10:49:56

Katofrp游戏联机优化与内网穿透实战指南

1. Katofrp联机工具深度解析Katofrp是一款专注于游戏联机场景的内网穿透工具&#xff0c;特别针对《我的世界》等沙盒游戏进行了优化。相比传统FRP方案&#xff0c;Katofrp最大的特点是内置了游戏数据传输优化算法&#xff0c;实测在《我的世界》联机场景下&#xff0c;延迟能控…

作者头像 李华
网站建设 2026/10/26 10:49:24

Linux文件系统IO操作与性能优化指南

1. Linux文件系统基础与IO操作Linux文件系统采用树状结构组织&#xff0c;所有文件和设备都以文件形式存在。理解文件系统的基本特性对后续掌握IO和缓存机制至关重要。1.1 文件描述符与文件表每个进程维护一个文件描述符表&#xff08;File Descriptor Table&#xff09;&#…

作者头像 李华
网站建设 2026/10/26 10:48:55

XXE漏洞原理、危害与防御全解析

1. XXE漏洞的本质与危害解析 XXE&#xff08;XML External Entity&#xff09;漏洞是XML解析器在解析用户可控的XML数据时&#xff0c;未对外部实体引用进行适当限制而产生的安全风险。这种漏洞之所以危险&#xff0c;是因为它突破了传统注入类漏洞的边界&#xff0c;能够实现从…

作者头像 李华
网站建设 2026/10/26 10:48:54

Home Assistant智能家居系统:架构解析与实战应用

1. 智能家居中枢系统的必要性 十年前我第一次接触智能家居时&#xff0c;市面上充斥着各种品牌的独立设备&#xff1a;A品牌的灯泡、B品牌的插座、C品牌的温控器&#xff0c;每个都需要自己的APP控制。这种碎片化体验让我意识到&#xff0c;我们需要一个统一的管理平台。这正是…

作者头像 李华
网站建设 2026/10/26 10:48:53

专科生必看:9款高效AI学习工具实战指南

1. 项目概述&#xff1a;专科生如何高效规避AI工具使用陷阱作为一名在职业教育领域深耕多年的从业者&#xff0c;我见过太多专科同学在AI工具选择上踩坑的案例。市面上打着"智能学习"旗号的产品层出不穷&#xff0c;但真正适合专科层次学习需求的工具却寥寥无几。这篇…

作者头像 李华