news 2026/10/16 3:45:48

Java远程文件下载实践:流式读取、断点续传与并发分段

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java远程文件下载实践:流式读取、断点续传与并发分段

前阵子要写一个内部资源同步工具,其中一个核心功能就是通过Java从远程URL文件流读取文件,并且下载到本地。需求听起来很简单:联网、拉流、写文件,完事。可真上了生产环境,文件一变大,网络一抖动,各种超时、断流、文件损坏的问题全冒出来了。

这篇文章把我从零实现这个下载功能的完整过程拆开讲,从最基础的原生写法,到带超时、重试、断点续传、并发分段下载的工程化方案,再加上实测中踩过的三个典型的坑。适合做数据采集、接口对接、运维自动化的Java后端同学参考,尤其是那些“下载看起来很简单,但生产环境总出幺蛾子”的场景。

1. 远程文件下载的真实场景与流式读取的核心逻辑

1.1 远程下载不是只有一个需求模板

“从远程URL下载文件”听起来是一个动作,实际拆开需求会分好几种情况:

数据同步类。比如某个数据平台每天凌晨生成全量导出包,我们需要通过HTTP接口拉取到本地服务器归档。这类需求的特点是文件大、定时跑、不能下载一半就崩。

素材拉取类。比如从内容服务拉图片、PDF、临时报表,URL通常是带签名的动态地址,几分钟内有效。这类需求的特点是并发高、请求数多,更看重连接速度和异常兜底。

二进制分发类。比如内网环境的安装包、依赖包分发,URL可能是固定路径,但文件动辄几百MB甚至几个GB。这类需求的特点是单文件体量大,最怕读到一半断流。

这三种场景都在问“java从远程url文件流读取文件,并且下载到本地”,但工程方案的侧重点完全不同。小文件用最简单的拷贝工具都没问题,大文件就必须考虑流的控制、续传和校验。

1.2 流式读取的核心:HTTP响应的本质就是一个InputStream

很多人在理解下载时有个误区,以为“远程文件”在服务器上是一个完整的、可以直接拿过来的文件。实际上HTTP传输的从来不是文件,而是字节流。

客户端发起请求后,服务器把响应体当作一个流发送过来,Java这边拿到的就是一个InputStream。这个流的内部可能经过TCP分包、可能被压缩(Content-Encoding: gzip)、可能被分块传输(Transfer-Encoding: chunked)。我们做下载的本质,就是把这个InputStream里的字节读出来,写到本地文件的OutputStream里。

理解这一点很重要,因为它决定了几个基础判断:

  • 如果直接用ByteArrayOutputStream把整个流读到内存,再一次性写文件,小文件没问题,1GB文件就直接内存爆炸。
  • InputStream.read()是阻塞式的,每次读取都会等待网络数据到达。如果连接空闲时间过长,read可能会抛出SocketTimeoutException。
  • 流的长度由Content-Length头告知,但这个值在压缩传输、分块传输、代理转发等场景下不一定可靠,后面会详细讲。

我习惯用一个类比来解释流式读取:服务器端有一个装满水的水塔,客户端要接一桶水回家,正确的做法是用一根水管(InputStream)把水接出来灌进自己的桶里(FileOutputStream)。有人非要把整个水塔的水先抽进自家的大水池(内存)再灌桶,小水塔行得通,大水塔必炸。

// 错误演示:把整个响应体读进内存再写文件 ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] data = IOUtils.toByteArray(inputStream); // 文件多大,内存就要吃多大 FileUtils.writeByteArrayToFile(new File("target.zip"), data);

1.3 为什么不能只依赖现成工具类

commons-io的FileUtils.copyURLToFile、JDK的URLConnection直接读写,这些对于一次性、几十MB的小文件确实够用。生产环境里我们需要的不只是“把文件拉下来”,而是一个带超时控制、失败重试、断点续传、完整性校验的下载组件。

工具类封装的缺点是“能跑,但出了事你没法插手”。比如:

  • 下载到一半网络断,工具类直接抛异常,下次又得从头开始。
  • 没有超时控制,某个URL永远不返回时线程就永远挂在那。
  • 无法拿到响应头里的Content-Length、ETag等元数据做判断。
  • 不支持自定义请求头,部分服务器对空User-Agent直接拒绝。

所以我的建议是:小文件、一次性脚本,随意用工具类;凡是长期运行、对稳定性和完整性有要求的下载任务,花半小时自己封装一层,一劳永逸。

2. 第一版从零实现:原生URLConnection的流式下载

2.1 最直白的写法

先看不需要第三方依赖的版本,这个版本跑通核心逻辑,也方便理解“流”的整个生命周期。

import java.io.*; import java.net.URL; import java.net.URLConnection; public class SimpleDownloader { public static void download(String fileUrl, String targetPath) throws IOException { URL url = new URL(fileUrl); // 连接对象可以设置很多参数,连接超时和读取超时优先配置 URLConnection connection = url.openConnection(); connection.setConnectTimeout(10_000); connection.setReadTimeout(30_000); // 很多服务器对空 User-Agent 会直接拒绝 connection.setRequestProperty("User-Agent", "Mozilla/5.0 (compatible; ResourceSync/1.0)"); // getInputStream 这一步才真正建立连接并发送请求 try (InputStream in = connection.getInputStream(); FileOutputStream out = new FileOutputStream(targetPath)) { byte[] buffer = new byte[8 * 1024]; // 8KB int len; long total = 0; // read 返回 -1 表示已经读到流末尾 while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); total += len; if (total % (8 * 1024 * 1024) == 0) { System.out.println("downloaded: " + total / (1024 * 1024) + " MB"); } } } } }

这个版本已经把“从远程URL文件流读取文件,并且下载到本地”的完整闭环走通了。URLConnection帮助我们完成了DNS解析、TCP连接、HTTP协议细节,我们只需要关注流的搬运。

代码逻辑不复杂,但有几个细节很容易被忽略,我逐个说。

2.2 容易被忽略的请求头与超时配置

首先是User-Agent。很多HTTP服务器,尤其是做了安全防护的URL签名服务,对空的或默认的User-Agent是会直接拒绝访问的。我在联调时遇到过某台文件服务器,用curl可以正常下载,用Java代码就403,排查半天发现只是UA问题。

设置User-Agent不是伪装浏览器,而是给服务器一个合法的客户端标识:

connection.setRequestProperty("User-Agent", "Mozilla/5.0 (compatible; ResourceSync/1.0)");

其次是两个超时要分开设:

  • setConnectTimeout:TCP连接建立的超时,也就是“连不连得上”。
  • setReadTimeout:两次数据读取之间的最大间隔,也就是“连上了但没数据”。

很多人只设一个,或者干脆不设。不设的结果是某个服务端卡住了,你的线程也跟着永久阻塞,最终占满线程池。我的经验是:连接超时10秒、读取超时30秒起步,大文件下载的读取超时可以放宽到60秒。

还有一个容易被坑的点:URLConnection默认不一定自动处理重定向。虽然HttpURLConnection默认setInstanceFollowRedirects(true),但某些服务器返回的302跳转地址是相对路径,或者跳转目标需要不同的UA头,就可能导致二次请求失败。原生实现遇到这类问题排查起来比较费劲,这也是我后面切到HttpClient的原因之一。

2.3 缓冲区大小和下载速度的关系

byte[] buffer = new byte[8 * 1024]这行代码看起来随意,其实对下载性能影响不小。

缓冲区决定了每次read操作从网络读取的字节数。太小(比如1KB),意味着同样一份文件需要执行更多次read和write,系统调用次数多、上下文切换频繁,吞吐量上不去。太大(比如1MB),并不会线性提升速度,反而占用更多内存,并且在网络波动大的时候更容易出现“一次read读不到满缓冲”的浪费。

我做过一组简单的对比测试,同样的内网HTTP文件、稳定的网络环境:

缓冲区大小单线程下载约200MB文件的耗时说明
1KB约11.5秒CPU占用高,系统调用频繁
8KB约4.2秒性能已经够用
64KB约3.1秒多数场景下的甜点区
256KB约3.0秒收益递减

所以第一版我用了8KB是为了简化示例,实际工程里我一般直接给64KB。这个值不用反复调,64KB~128KB基本是单线程下载的大众选择。

3. 工程化改造:HttpClient、超时重试与断点续传

3.1 为什么从URLConnection切到Apache HttpClient

原生URLConnection能跑通,但在生产环境里面对下面几个问题时会很吃力:

  • 没有连接池,每个请求都要重新建立TCP连接,频繁下载时耗时明显。
  • 重试逻辑要自己用循环包一层,代码越写越乱。
  • 设置请求头、读取响应头都很啰嗦。
  • 连接管理、代理、TLS配置全靠手动,复杂场景下容易出错。

Apache HttpClient(或者OkHttp)把这些基础设施做好了。尤其是连接池:同一目标域名下,复用已建立的TCP连接,减少握手次数,对高并发下载任务的帮助非常明显。

我在项目里用的是Apache HttpClient 4.x,团队Spring项目里也有人用RestTemplate,但RestTemplate底层的URLConnection其实不是为流式下载设计的,所以我更推荐直接用HttpClient API。

import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import java.io.IOException; import java.io.InputStream; import java.io.FileOutputStream; public class HttpClientDownloader { public static void downloadWithRetry(String fileUrl, String targetPath) throws IOException { IOException lastException = null; for (int attempt = 0; attempt <= 3; attempt++) { try { if (attempt > 0) { // 简单指数退避:第一次失败等500ms,第二次1s,第三次2s Thread.sleep(500L * (1L << (attempt - 1))); } downloadOnce(fileUrl, targetPath); return; } catch (IOException e) { lastException = e; System.err.println("download failed, attempt=" + attempt + ", cause=" + e.getMessage()); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new IOException("interrupted", ie); } } throw lastException; } private static void downloadOnce(String fileUrl, String targetPath) throws IOException { RequestConfig config = RequestConfig.custom() .setConnectTimeout(10_000) // 连接建立超时 .setSocketTimeout(60_000) // 数据读取超时,大文件放宽到60s .setConnectionRequestTimeout(10_000) // 从连接池获取连接超时 .build(); try (CloseableHttpClient client = HttpClients.custom() .setDefaultRequestConfig(config) .build()) { HttpGet httpGet = new HttpGet(fileUrl); httpGet.setHeader("User-Agent", "Mozilla/5.0 (compatible; ResourceSync/1.0)"); try (CloseableHttpResponse response = client.execute(httpGet); InputStream in = response.getEntity().getContent()) { try (FileOutputStream out = new FileOutputStream(targetPath)) { byte[] buffer = new byte[64 * 1024]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } } } } } }

注意:每次重试时都创建新的HttpClient其实不划算,实际工程里应该把HttpClient做成单例或通过连接池管理,我只为了示例清晰才这样写。重试还有一个关键点:前一次失败时如果已经写了部分数据,直接再new FileOutputStream(fileName)会覆盖掉之前的内容,从零开始。这在下载大文件时极度浪费。

3.2 下载任务的超时与重试策略设计

重试不是“失败了就再来一次”这么简单,我把自己的设计原则列一下:

  • 重试只针对“可恢复的异常”。比如SocketTimeoutException(读超时)、ConnectTimeoutException(连接超时)、IOException(连接被重置)。对于FileNotFoundException、403这类确定性失败,重试没有意义。
  • 重试次数要有限制。我通常设3次,超过后让任务直接失败并告警。无限重试会让系统在故障期间做大量无效请求。
  • 重试间隔要递增。第1次失败等500毫秒,第2次1秒,第3次2秒。这是为了避免服务端还没恢复时,客户端又涌进来一群请求。
  • 记录每次失败的原因。生产环境排查时,“连续重试3次失败,原因分别是超时、连接重置、超时”这样的日志价值远高于“下载失败”。

如果服务端支持Range请求(响应头里有Accept-Ranges: bytes),重试时最优雅的做法是断点续传,也就是从上次写入的字节数开始继续下载。

3.3 断点续传的关键:Range头与206响应

断点续传利用了HTTP协议的一个基础能力:Range请求。

客户端在请求头里加上Range: bytes=1024-,意思是“从第1024个字节开始返回,跳过前面1024个字节”。服务端如果支持,会返回206 Partial Content,响应体就是剩余部分。

实现起来分两步:

第一步,下载前先看本地是否已有部分文件,如果有,记录已下载的字节数:

File targetFile = new File(targetPath); long downloaded = 0L; if (targetFile.exists()) { downloaded = targetFile.length(); // 注意:需要保证之前写入的数据是正确的 }

第二步,发送带Range头的请求,读取剩余部分并追加写入:

HttpGet httpGet = new HttpGet(fileUrl); if (downloaded > 0) { httpGet.setHeader("Range", "bytes=" + downloaded + "-"); } try (CloseableHttpResponse response = httpClient.execute(httpGet)) { int status = response.getStatusLine().getStatusCode(); if (status == 206) { // 服务端支持续传,正常读取剩余内容 } else if (status == 200 && downloaded > 0) { // 服务端不支持Range,只能整个文件重新下 // 这里要重置 downloaded = 0,并清空已有文件 } else if (status >= 400) { throw new IOException("HTTP error: " + status); } try (InputStream in = response.getEntity().getContent(); FileOutputStream out = new FileOutputStream(targetPath, downloaded > 0)) { // 追加模式 byte[] buffer = new byte[64 * 1024]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } } }

一个容易被忽略的细节:用FileOutputStream(targetFile, true)追加写入时,如果已下载的数据本身是坏的,追加也没有意义。所以生产级组件需要每次记录下载块的状态,或者下载完成后做整体校验。

4. 大文件场景的性能与完整性:分段并发与校验

4.1 长时间运行的下载任务为什么容易中途失败

单线程断点续传已经能解决“失败后从头再来”的问题,但大文件下载还有一个隐形的敌人:连接生命周期。

CDN、网关、机房防火墙经常会有空闲连接回收机制。下载一个2GB文件,如果平均下载速度只有几MB每秒,整个任务可能持续十几分钟。这期间如果某次读取间隔超过了服务端空闲回收阈值,连接会被静默断开。客户端这边表现就是:卡住一段时间,然后抛SocketTimeoutException或Connection reset。

遇到这类问题,除了把SocketTimeout调大,还有一种思路是从架构上规避:把整个大文件拆成多个小段,并发下载,每段的生命周期短,对连接的依赖也就弱了。

4.2 分段并发下载的实现思路

下载前先发一个HEAD请求或者直接发GET请求读响应头,拿到两个关键信息:

  • Content-Length:文件总大小。
  • Accept-Ranges:是否支持分段下载,支持的话通常是bytes。

拿到文件总大小后,按固定块大小分段(比如每段10MB),每个分段发起一个带Range头的请求。写入端使用RandomAccessFile,每个分段独立定位到自己的偏移位置写入,互不干扰。

核心代码如下:

import java.io.IOException; import java.io.RandomAccessFile; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class ConcurrentDownloader { public static void download(String fileUrl, String targetPath, long fileSize, int chunkSize, int threads) throws Exception { File tmpFile = new File(targetPath + ".tmp"); int chunkCount = (int) ((fileSize + chunkSize - 1) / chunkSize); ExecutorService pool = Executors.newFixedThreadPool(threads); CountDownLatch latch = new CountDownLatch(chunkCount); try (RandomAccessFile raf = new RandomAccessFile(tmpFile, "rw")) { for (int i = 0; i < chunkCount; i++) { long start = (long) i * chunkSize; long end = Math.min(start + chunkSize - 1, fileSize - 1); pool.submit(() -> { try { byte[] data = downloadRange(fileUrl, start, end); raf.seek(start); // 每段写入各自的位置 raf.write(data); } catch (IOException e) { throw new RuntimeException(e); } finally { latch.countDown(); } }); } latch.await(); } finally { pool.shutdown(); } // 全部完成后再改名,保证原子性 if (!tmpFile.renameTo(new File(targetPath))) { throw new IOException("rename failed: " + tmpFile.getName() + " -> " + targetPath); } } private static byte[] downloadRange(String fileUrl, long start, long end) throws IOException { // 发起带 Range: bytes=start-end 的请求,读取并返回字节数组 // 注意每段大小固定,这里段大小需要控制在内存可接受范围内(比如10MB) ... return data; } }

这个方案里有两个坑需要特别说明:

  • 每段下载的结果如果都先放到内存再写盘,段大小必须控制好。10MB一段,4个线程并发,最多占40MB内存,基本可接受。但如果段大小设为1GB,内存直接扛不住。
  • CountDownLatch的计数必须和实际提交的任务数一致。如果某个任务抛出异常导致countDown()没有被执行,主线程会永远卡在latch.await()上。生产环境建议把countDown()放在finally块,并且对异常做统一收集。

分段并发带来的收益很直接:4个线程并发下载,理论速度接近单线程4倍。但也不是线程越多越好,线程过多会和服务端之间产生大量并发连接,可能触发限流或导致服务端压力过大。我的经验是,内网大文件下载4~8个线程比较稳妥,公网则2~4个线程,保持克制。

4.3 文件完整性校验:下载完成不等于文件正确

分段并发下载完成后,不能直接认为文件就是对的。每一段都下载成功,不代表所有段拼起来是一个完整的文件,也不代表没有字节缺失或错位。

校验的做法是在下载完成后对整个文件计算哈希值,和服务端的校验值对比。如果服务端通过Content-MD5头返回了MD5,直接用;如果没有,可以在下载完成后请求一个同名.md5文件或者通过其他渠道拿校验值。

import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.security.DigestInputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; public class FileChecksum { public static String md5(File file) throws IOException, NoSuchAlgorithmException { MessageDigest md5 = MessageDigest.getInstance("MD5"); try (InputStream in = new FileInputStream(file)) { byte[] buffer = new byte[64 * 1024]; int len; while ((len = in.read(buffer)) != -1) { md5.update(buffer, 0, len); } } byte[] digest = md5.digest(); StringBuilder sb = new StringBuilder(); for (byte b : digest) { sb.append(String.format("%02x", b)); } return sb.toString(); } }

校验失败时,最稳妥的做法是删掉临时文件,整个重新下载。有人可能觉得“只重下坏掉的那一段”更高效,但实操中你需要知道坏在哪一段,而确定坏段本身又需要每段算哈希做对比,复杂度很高。除非文件实在太大,否则“校验失败一律全量重下”是最不容易出错的策略。

4.4 临时文件与原子性

下载过程中的文件状态是不确定的,可能只写了一半,也可能还在下载中。这种情况下如果外部程序扫描目录,很可能把残缺文件当成正式文件读取。

我的习惯是:下载时写到一个.tmp后缀的临时文件,所有分段都完成、校验也通过之后,再通过renameTo或Files.move改成最终文件名。renameTo在同一个文件系统内是原子操作,线上程序不会读到“半个文件”的状态。

Path tmp = Paths.get(targetPath + ".tmp"); Path target = Paths.get(targetPath); Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);

5. 实测中遇到的三类坑与完整排查链路

5.1 坑一:偶发Read Timeout,但服务端日志显示请求秒回

现象是下载大文件时,跑两分钟到三分钟左右必然报SocketTimeoutException: Read timed out。检查服务端日志,每个请求都是秒级处理完,看起来完全正常。

排查链路:

  1. 先怀疑自己代码问题。检查是不是SocketTimeout配置太小。但已经调到60秒,还是卡在同一个时间点附近失败。
  2. 接着怀疑网络质量。用ping持续测试,丢包率极低。
  3. 抓包查看TCP连接状态。发现连接在持续空闲超过60秒后被对端发送了RST包。也就是说,不是没数据,而是服务端主动断开了空闲连接。
  4. 查服务器和中间链路,发现是防火墙对长连接的空闲回收策略。默认回收阈值是60秒,断流时间点正好在60秒附近。

解决方式有两个方向:一是把客户端SocketTimeout适当调大,覆盖空闲回收的时间窗口;二是在应用层做“心跳保活”,定期发送零字节探测包。实操中更有效的是:不要试图对抗防火墙,直接把大文件下载改成分段并发,单段下载时长控制在回收阈值以内,问题自然消失。

5.2 坑二:Content-Length和实际下载字节数对不上

下载完成后,发现本地文件大小比预期的Content-Length小或者大,而且程序没有报错。

排查链路:

  1. 先打印响应头。发现Content-Encoding: gzip。服务端开启了压缩传输,Content-Length表示的是压缩后的字节数,但 HttpClient 自动解压了响应体,所以读到的字节数和Content-Length对不上。
  2. 继续挖,发现如果下载的文件本身是zip包或者二进制图片,服务端压缩几乎没什么收益,反而白白消耗CPU。
  3. 解决方式是明确告诉服务端不要压缩:Accept-Encoding: identity,或者干脆单独处理解压流程。

这个坑的教训是:下载文件时,一定要先看响应头的Content-Encoding。如果服务端返回了gzip,而你下载的又是一个压缩包,双重压缩会让使用者非常困惑。

5.3 坑三:文件名编码与URL中的中文问题

URL里包含了中文参数或者文件名,直接new URL然后下载,可能抛出MalformedURLException。

排查链路:

  1. 发现URL里的中文没有做URL编码。Java的URL对象要求URL是合法编码格式,中文和特殊字符必须转成%XX形式。
  2. 对URL做编码时踩了第二个坑:对整个URL调用URLEncoder.encode会把/、?、&这些分隔符也转义掉,URL直接被改坏。
  3. 正确做法是只对路径中和查询参数中的非ASCII字符做编码。实践中我通常让上游直接把URL编码好再传过来,避免二次处理。

还要处理服务端返回的文件名。响应头里的Content-Disposition可能带文件名,格式有filename="xxx.pdf"和filename*=UTF-8''%E4%B8%AD%E6%96%87.pdf两种。后者是RFC 5987定义的编码格式,需要先取filename*,再做UTF-8解码。解码后还要替换Windows文件名里的非法字符:\/:*?"<>|。

5.4 踩完坑后沉淀的下载组件设计建议

经历过这几个坑之后,我在团队内部把下载组件从“一个工具方法”重构成了一个可独立运行的模块,几个设计决定回头看来很值得:

  • 所有下载任务对外暴露统一的接口,内部实现分“单线程版”“续传版”“分段并发版”,调用方按文件大小和网络环境选择。
  • 进度信息通过回调接口上报,而不是在组件内部打印日志。这样上层可以做进度条、做监控告警。
  • 响应头信息(Content-Length、Content-Encoding、Accept-Ranges、ETag)在下载前就被解析出来,后续逻辑都基于这些元数据判断,避免边下载边发现异常。
  • 每个文件下载的耗时、平均速度、重试次数都会记录到日志。长期跑下来,这份日志就是排查网络环境问题的重要依据。

写下载组件这类基础功能,最大的体会是“能尽早暴露的问题,绝不拖到下载完成后才发现”。第一版代码可能只需要实现InputStream到FileOutputStream的搬运,但超时、重试、断点续传这些骨架最好一开始就预留好接口,哪怕当前用不上。否则等到生产环境出了问题,你面临的不是“加一个功能”,而是“重构整个下载流程”。

最后分享一个我很习惯的调试动作:正式下载前,先打印响应头。Content-Length、Content-Type、Content-Encoding、Accept-Ranges这四行信息,能排除一半的下载问题。尤其是你对接的服务端不是你自己的系统时,通过响应头判断它的行为,比反复测试和猜谜高效得多。

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

2024年CSP-J复赛真题及题解: T2 地图探险

2024年CSP-J复赛真题及题解: T2 地图探险 题目描述 小 A 打算前往一片丛林去探险。丛林的地理环境十分复杂,为了防止迷路,他先派遣了一个机器人前去探路。 丛林的地图可以用一个 n n n 行 m m m 列的字符表来表示。我们将

作者头像 李华
网站建设 2026/10/16 3:45:31

超市数据分析的本质是业务翻译,不是技术建模

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 3:44:54

Ubuntu下检测鼠标键盘活动状态:从事件读取到长期稳定运行

1. 为什么要在 Ubuntu 上盯住鼠标键盘的"沉默"很多人第一次听到"检测鼠标键盘是否活动"这个需求&#xff0c;第一反应是&#xff1a;这有什么难的&#xff0c;插上不就能用吗&#xff1f;但真正在 Ubuntu 上做过长期运行设备、无人值守终端、公共展示机或者…

作者头像 李华
网站建设 2026/10/16 3:43:44

Pygame实战:从零编码弹球打砖块游戏,新手也能快速跑通

很多朋友学完Python基础语法之后&#xff0c;卡住的往往不是语法本身&#xff0c;而是“不知道这些语法能拼成一个什么玩意儿”。我在这个阶段折腾了不少项目&#xff0c;命令行程序、数据处理脚本都试过&#xff0c;但总感觉少点“我能做出来给人玩的东西”的实感。直到碰了Py…

作者头像 李华
网站建设 2026/10/16 3:43:41

广东省残疾人公益基金会助力2026“凯瑞麟杯”GPCC粤港澳大湾区匹克球城市挑战赛广州赛区决赛圆满收官

广东省残疾人公益基金会助力2026“凯瑞麟杯”GPCC粤港澳大湾区匹克球城市挑战赛广州赛区决赛圆满收官9月26日至27日&#xff0c;2026“凯瑞麟杯”GPCC粤港澳大湾区匹克球城市挑战赛广州赛区城市决赛在白云区永旺梦乐城POPO网球匹克球运动中心圆满收官。 本届赛事汇聚约500名选手…

作者头像 李华
网站建设 2026/10/16 3:43:36

正交多分辨分析:从空间嵌套到Mallat算法的小波基础

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华