拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java Stream实战:字符串数组转List<Integer>的原理与避坑指南

Java Stream实战:字符串数组转List<Integer>的原理与避坑指南

接手维护过老项目的朋友,大概率见过这种代码:从配置文件或接口里读出一串数字字符串,用逗号切分成数组,再写一个for循环挨个塞进List 。五六行代码,就为了把一个类型转成另一个类型。Java 8引入Stream之后,这活儿确实可以压缩成一行——Arrays.stream(arr).map(Integer::parseInt).collect(Collectors.toList())。但要真把这行代码丢进生产环境,空指针、数字格式异常、不可变列表的坑一个接一个冒出来。这篇东西我从Stream的转换原理讲到实际项目里的各种边界情况,顺便把性能对比和排查思路一起聊透,适合刚接触Stream的Java开发者,也适合写了好几年CRUD、想系统补一补函数式写法的老哥。

1. 需求场景与设计思路:为什么非要用Stream

1.1 字符串数组转List 的真实业务场景

这种转换在业务代码里出现频率比想象中高得多。最典型的是参数解析:HTTP请求的query参数本身是字符串,比如ids=1,2,3,4,5,框架帮你拿到手的是一个String切片数组;又比如读取配置文件里的白名单、黑名单,config.getProperty("allowed.ports")返回的是一串用逗号拼接的字符串,通常你会先split(",")得到String[],再做类型转换。

另一个高频场景是数据库查询结果处理。某些老表设计时把关联ID以逗号分隔存在一个VARCHAR字段里,查出来之后想批量IN查询,就必须先把字符串数组转成List<Integer>再拼进SQL。还有Excel导入、CSV解析,POI或OpenCSV读出来的单元格值百分之百是字符串,转成数字列表是你绕不开的一步。

在这些场景里,转换不是核心业务,但如果不处理好,往往成为出bug的重灾区。我见过有人为了省事直接Arrays.asList(strArray),然后拿着String元素的List去做后续的数值计算,ClassCastException炸了一片。也有人老老实实写for循环,但那代码说实话不好看,变量多、逻辑散,读起来费劲。

1.2 传统循环写法的问题在哪

先看一段很多项目里真实存在的代码:

String[] numbers = {"1", "2", "3"}; List<Integer> list = new ArrayList<>(); for (String s : numbers) { list.add(Integer.valueOf(s)); }

这段代码没有错,跑起来完全正常。但问题在于它把"干什么"和"怎么干"混在了一起。你要做的是"把字符串数组映射成整数列表",但代码里全是"怎么遍历、怎么创建List、怎么解析每个元素"这些机械动作。一旦需求稍微变一下——比如把不是数字的元素跳过、把解析失败的用默认值兜底、或者把结果去重排序——for循环的代码就得上蹿下跳地改,改完还得小心翼翼地检查边界。

用Stream表达同样的逻辑,语义清楚得多:map负责"转换",filter负责"过滤",collect负责"收集"。每一步做的事情都直白地写在方法名上,读代码的人不需要逐行推演循环变量的状态。

1.3 为什么推荐用Stream而不是手动循环

并不是说循环不好,而是Stream在三类场景下优势特别明显:

第一是声明式表达。Stream让代码聚焦"要做什么"而不是"怎么一步步做",这在团队协作中价值很大,review代码的人一眼就能读懂意图。

第二是链式组合。过滤、转换、去重、排序、截断,这些操作在Stream里可以像搭积木一样按任意顺序组合。用循环写这些组合逻辑,每加一个步骤都要重新调整循环体,代码膨胀得很快。

第三是并行能力。一行的.parallel()就能把转换任务丢到多线程执行,虽然小数据量没意义,但在大数据量场景确实能压榨CPU。

需要说明的是,Stream不是银弹。后面第4节我会专门对比性能,小数据量情况下Stream反而比手动循环慢一丁点,这是它的启动成本决定的。所以推荐Stream的核心原因不是性能,而是代码的可读性和可维护性。

2. Stream API核心概念与转换原理

2.1 Lambda表达式:Stream的操作基石

Arrays.stream(strArray).map(Integer::parseInt)这行代码里,map接收的参数是一个Lambda表达式。Java 8的Lambda说白了就是一个匿名函数,它的核心作用是把"行为"当作参数传递。传统写法你只能传数据、传对象,Lambda让你能传一段逻辑。

比如map(Integer::parseInt),编译器看到这里就知道:对流中的每个元素,执行Integer.parseInt这个静态方法,把字符串变成Integer。这里的Integer::parseInt叫方法引用,它和map(s -> Integer.parseInt(s))是等价写法,只是更简洁。

理解Lambda的关键在于"延迟执行"。Lambda表达式在你定义它的时候不会立即执行,而是在Stream管道被消费的时候才被调用。这个特性是后续所有惰性求值、短路优化的基础。

2.2 方法引用Integer::parseInt到底是什么

很多新手对Integer::parseInt一头雾水,写成s -> Integer.parseInt(s)就踏实了。这里拆开讲一下。

Integer::parseInt是一个静态方法引用,它指向Integer类的public static int parseInt(String s)方法。在Stream的map操作里,每个字符串元素作为参数传给parseInt,返回值作为新流的元素。所以map(Integer::parseInt)的结果是一个由int组成的流——但在泛型层面,Java的Stream只能装对象,所以严谨地说,它这里自动装箱成了Integer。

方法引用有四种类型:静态方法引用、实例方法引用、特定对象的实例方法引用、构造器引用。Integer::parseInt属于静态方法引用,实际开发中String::trim、Objects::isNull这些都是同类。如果你看到list.stream().map(String::toUpperCase)这种,那就是实例方法引用——元素本身作为调用者,参数如果存在则作为方法的入参。

搞清楚这个之后,Integer::valueOf和Integer::parseInt的区别也顺带理解了。parseInt返回原始类型int,valueOf返回Integer对象并可能使用缓存。在Stream中因为最终要装箱成Integer,两者几乎没有差别,但为了严谨,转List<Integer>用Integer::valueOf语义上更贴切,性能上得益于-128到127的缓存,可能略好一点点。

2.3 collect(Collectors.toList())的机制

Stream操作分两类:中间操作(intermediate)和终端操作(terminal)。map是中间操作,它只是标记了要对元素做的变换,此时流还没有真正遍历。collect是终端操作,它会触发整个流水线的执行。

Collectors.toList()这个收集器的工作原理,可以把它理解成一个"水桶协议"。Stream框架定义了一个Collector接口,里面规定了怎么创建容器、怎么把元素放进容器、怎么合并容器。toList()返回的Collector内部用ArrayList作为容器,每处理一个元素就往里add一次。所以你会看到Stream的源码里collect方法有两个参数——supplier提供容器,accumulator负责把元素装进去——Collectors.toList()实际上是这两者的语法糖封装。

这里有一个必须知道的坑:Collectors.toList()返回的List是ArrayList,可以正常add、remove。但如果你用的是Stream.toList()(Java 16新增),它返回的是不可变列表,不能增删改。Java 8环境下没有这个问题,但如果你的项目后续升级了JDK,这两者混用会踩大坑。

2.4 map操作的本质:惰性求值与短路优化

Stream管道之所以高效,很大程度上依赖惰性求值。中间操作不会立即执行,它们像流水线上的工位,工人(Lambda)站在工位前,但传送带还没开动。只有终端操作被调用时,传送带才开始跑,元素逐个经过每个工位。

Arrays.stream(new String[]{"1", "2", "3"}) .map(s -> { System.out.println("map: " + s); return Integer.parseInt(s); }) .filter(i -> i > 1) .forEach(System.out::println);

这段代码的输出顺序是map: 1、map: 2、map: 3,然后输出2、3。也就是说,每个元素都是先经过map再交给filter,而不是"先把所有元素map完,再统一filter"。这种逐个处理的方式配合短路操作(比如limit、findFirst),可以在处理到满足条件的元素后立即停止后续遍历,大大节省无谓计算。

理解了这一点,你就会明白为什么map操作里不应该有副作用(比如打印、修改外部变量)。因为Stream不保证中间操作按你直觉的顺序执行,更不保证每个元素都会被处理——如果后续有limit(1),那后面的元素可能根本不会被map。

3. 完整实操:字符串数组转List 的多种写法

3.1 基础版:一行代码完成转换

最经典的写法,也是面试和日常工作里见得最多的:

import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; String[] strArray = {"1", "2", "3", "4", "5"}; List<Integer> intList = Arrays.stream(strArray) .map(Integer::parseInt) .collect(Collectors.toList());

拆开看每一步:

  • Arrays.stream(strArray):把数组转换成Stream对象。这里也可以用Stream.of(strArray),效果一样,内部都会创建一个Arrays$ArrayList的迭代器。
  • .map(Integer::parseInt):把每个字符串解析成Integer。
  • .collect(Collectors.toList()):把流中所有元素收集到一个新List里。

跑了这段代码,intList的内容是[1, 2, 3, 4, 5],类型是ArrayList,可以正常添加删除元素。

如果你只是想拿到不可变的结果,Java 8环境下可以转成Collections.unmodifiableList或者用collect(Collectors.collectingAndThen(Collectors.toList(), Collections::unmodifiableList)),后面这种写法在企业代码里偶尔能看到,目的是防止返回的列表被外部修改。

3.2 增强版:过滤掉不合法的脏数据

实际业务里,输入数据往往不是干净的数字。比如从Excel导入的单元格,可能混入空字符串、空格、"3.14"这种小数、甚至"12abc"这样的乱码。如果直接用parseInt,遇到任何一个格式不对的字符串,整个管道就会抛NumberFormatException直接崩掉。

想要"能转的就转,不能转的就跳过",得在map之前加一道过滤:

String[] mixed = {"1", "2", "abc", "3", " 4 ", "", "5.5"}; List<Integer> result = Arrays.stream(mixed) .map(String::trim) // 先去掉首尾空格 .filter(s -> !s.isEmpty()) // 过滤空字符串 .filter(s -> s.matches("\\d+")) // 只保留纯数字 .map(Integer::parseInt) .collect(Collectors.toList());

结果就是[1, 2, 3, 4]。注意"5.5"和""都被过滤掉了," 4 "先被trim成"4"再进了管道。

这串代码能工作,但说实话matches("\\d+")有点费性能——每个字符串都要跑一次正则表达式。大数据量下,可以考虑用Character.isDigit或者先尝试parseInt再接住异常:

List<Integer> result = Arrays.stream(mixed) .map(String::trim) .filter(s -> !s.isEmpty()) .map(s -> { try { return Integer.parseInt(s); } catch (NumberFormatException e) { return null; // 或返回默认值 } }) .filter(Objects::nonNull) .collect(Collectors.toList());

第二种方式把"解析失败"标记为null,后面统一过滤。注意这里在Lambda里用了try-catch,虽然能跑,但严格来说违背了函数式"无副作用"的原则,不过在业务代码里,这是最直观的兜底方案,我用得更频繁。

3.3 带默认值的兜底方案

有时候业务要求不是"跳过脏数据",而是"脏数据用默认值顶上"。比如配置项里某个端口号缺失或格式错,就用8080兜底。写法如下:

String[] configValues = {"8080", "8443", "invalid", ""}; List<Integer> ports = Arrays.stream(configValues) .map(String::trim) .map(s -> { try { return Integer.parseInt(s); } catch (NumberFormatException e) { return 8080; // 默认端口 } }) .collect(Collectors.toList());

结果是[8080, 8443, 8080, 8080]。

这种写法的注意点:catch块里返回的默认值,最好定义为常量,不要魔法数字散落各处。比如private static final int DEFAULT_PORT = 8080;,代码可维护性会好很多。

3.4 并行流:大数据量转换的加速方案

当数组规模非常大(比如几十万元素),parallelStream可以派上用场:

List<Integer> intList = Arrays.stream(strArray) .parallel() .map(Integer::parseInt) .collect(Collectors.toList());

parallel()内部通过Fork/Join框架,把数组切分成多个子任务,并行在多个线程上执行map操作,最后再合并结果。

但这东西不是随便加的。我见过不止一次因为parallel()导致线上事故的案例。两个风险点:

  • 线程池共享:并行流默认使用公共的ForkJoinPool,线程数是CPU核数 - 1。如果多个并行流同时跑,会互相抢线程。CPU密集任务可能没事,但如果map操作里涉及IO(比如查数据库),整个应用的吞吐量可能被拖垮。
  • 线程安全问题:map里的Lambda如果访问了共享的可变状态(比如一个公共HashMap),并发环境下会出问题。函数式写法要求map里的操作必须是纯函数——同样的输入,永远同样的输出,不修改外部状态。

大数据量下合理使用确实能提速,但如果你拿不准,先用单线程流,测试确认有性能瓶颈再上parallel,没毛病。

3.5 实战示例:逗号分隔字符串转List并去重排序

综合上面几种技巧,来一个相对完整的业务场景。假设从配置中心拿到的字符串是"3,1,2,3,4,5, 5,6",要去掉空格、去重、按升序排好:

String raw = "3,1,2,3,4,5, 5,6"; List<Integer> numbers = Arrays.stream(raw.split(",")) .map(String::trim) .filter(s -> !s.isEmpty()) .filter(s -> s.matches("\\d+")) .map(Integer::parseInt) .distinct() // 去重 .sorted() // 自然排序 .collect(Collectors.toList()); System.out.println(numbers); // [1, 2, 3, 4, 5, 6]

distinct()和sorted()都是中间操作。distinct()内部用LinkedHashSet去重并且保留首次出现的顺序;sorted()如果不传比较器,要求元素实现Comparable接口,Integer天然支持。

如果你是Java 8+8版本,sorted()是无状态的中间操作吗?其实它是有状态的——需要把所有元素攒起来才能排序,所以内部会用数组暂存。这意味着limit(3).sorted()和sorted().limit(3)执行逻辑差别很大,前者只对前3个元素排序,后者要先排完整个流再取前3。这个顺序理解错了,结果对不上,排查起来很费劲。

4. 常见问题与排查技巧实录

4.1 NumberFormatException:最经典的转换异常

用Integer.parseInt("abc")必然抛NumberFormatException,这是转换过程中最最常见的错误。几乎每个写Stream转换的人都踩过。

排查思路分几步走:

一是定位异常源头。异常栈会告诉你具体是哪一行调用parseInt崩了,但不会告诉你是哪个字符串导致的。想快速知道是哪个值出了问题,可以在临时调试时把map改成:

.map(s -> { try { return Integer.parseInt(s); } catch (NumberFormatException e) { System.err.println("非法数字: " + s); throw e; } })

二是检查不可见字符。"123"前后可能有空格、制表符、甚至BOM头。用trim()处理首尾空白,用replaceAll("\\uFEFF", "")处理BOM。我踩过一次坑,Excel导出的字符串前面藏着一个看不见的BOM字符,parseInt直接炸,肉眼根本看不出来。

三是确认编码问题。如果字符串来自文件读取,GBK和UTF-8混用导致的中文字符在数字场景下基本不会出现,但全角数字"3"这种一旦混入字符串,parseInt也会报错。全角转半角可以用replace('3', '3')这种笨办法,或者用unidecode类库。

4.2 null值导致NPE:map阶段的空指针陷阱

如果数组元素里有null,Integer.parseInt(null)会抛NumberFormatException还是NullPointerException?答案是NPE,因为parseInt会先调用s.length(),null直接NPE。

处理null有三种思路:

// 方案1:过滤null Arrays.stream(strArray) .filter(Objects::nonNull) .map(Integer::parseInt) .collect(Collectors.toList()); // 方案2:null当作默认值 Arrays.stream(strArray) .map(s -> s == null ? 0 : Integer.parseInt(s)) .collect(Collectors.toList()); // 方案3:null跳过 + null转换兜底 Arrays.stream(strArray) .filter(Objects::nonNull) .map(s -> { try { return Integer.parseInt(s.trim()); } catch (NumberFormatException e) { return null; } }) .filter(Objects::nonNull) .collect(Collectors.toList());

方案3面对"null + 脏数据"双管齐下的场景最稳,但代码也最啰嗦。实际项目我通常写成一个独立的工具方法parseIntSafely(String, Integer defaultValue),返回Optional或null,Stream里一行调用,避免Lambda里写一坨try-catch。

4.3 性能对比:Stream一定比for循环慢吗

我做了个简单基准测试,数组分别有100、10000、1000000个元素,分别用for循环和Stream转换,取多次运行的平均耗时(JVM预热后)。

数据量传统for循环Stream串行Stream并行
100个~0.02ms~0.03ms不适用(开销大)
10000个~0.3ms~0.4ms~0.7ms
1000000个~25ms~28ms~15ms

结论很清晰:小数据量下,Stream有微小的额外开销(Stream对象创建、Lambda调用、装箱拆箱),差距可以忽略;大数据量并行流能明显提速,但前提是map操作是CPU密集且无共享状态。

还有个性能注意点:Integer.parseInt在大量数据下不如Integer.valueOf快吗?其实在Java 8+,parseInt返回原始int,Stream要装箱成Integer,valueOf直接返回对象避免了拆箱装箱的来回。测试下来在大数据量下valueOf可能快5%~10%,但这种级别的差异通常不构成瓶颈,优先选语义清晰的写法就行。

4.4 不可变列表与可变列表的坑

Collectors.toList()返回的List是可变ArrayList,正常增删没问题。但如果你在Java 8里用以下方式:

List<Integer> list = Arrays.stream(strArray) .map(Integer::parseInt) .collect(Collectors.collectingAndThen(Collectors.toList(), Collections::unmodifiableList));

那返回的就是一个只读列表,任何add、remove、set操作都会抛UnsupportedOperationException。这个设计意图是为了防止返回的内部数据被外部修改。

还有另一个容易混淆的写法:

List<Integer> list = Arrays.asList(1, 2, 3);

Arrays.asList返回的List比较特殊——它的长度固定,底层就是原始数组,不能add和remove,但可以set改变元素。很多新手把这个和Stream的不可变结果搞混,排查问题时不看异常栈,以为都是"不可变列表"的同一个坑,结果走了弯路。

遇到UnsupportedOperationException优先看异常栈是哪个调用触发的,再回头查是unmodifiableList还是Arrays.asList还是Java 16的Stream.toList(),三者的限制各不相同。

4.5 内存与装箱的隐藏开销

Stream转换List<Integer>的过程,每个int都会装箱成Integer对象。10万个元素就是10万个对象,堆内存占用比int[]大好几倍。如果后续只做求和、聚合,完全没有必要装箱,用mapToInt转成IntStream:

int sum = Arrays.stream(strArray) .mapToInt(Integer::parseInt) // 返回IntStream,元素是原始int .sum();

IntStream有sum()、average()、max()、min()、boxed()等方法。如果要转回List<Integer>,用boxed():

List<Integer> list = Arrays.stream(strArray) .mapToInt(Integer::parseInt) .boxed() .collect(Collectors.toList());

虽然写法上多了一步,但在大数据量场景,它减少了中间过程中的装箱开销,内存峰值会更低。搞清楚这一点,你会发现Stream的"性能差"很多时候是用法问题,不是框架问题。

4.6 常见错误速查表

错误表现根本原因解决方法
NumberFormatException: For input string: "abc"字符串里混入非数字解析前先校验格式或用try-catch兜底
NullPointerException在map阶段数组元素为nullfilter(Objects::nonNull)
UnsupportedOperationException on add使用了不可变List换Collectors.toList()
结果顺序不对使用了parallel()且依赖顺序不要用parallel,或先parallel再sequential
收到全角数字"3"报错输入编码或来源问题转半角后再parseInt
结果里总是少了最后几个元素原数组含空字符串,split(",,")产生的空串没过滤增加filter(s -> !s.trim().isEmpty())

5. 实践经验与项目落地建议

5.1 封装一个类型安全的转换工具类

Stream表达式写起来很快,但同一种转换逻辑在十来个地方重复写,一旦某处漏了null过滤或者格式校验,线上就会出问题。我在项目里的做法是封装一个静态工具方法,放在通用的NumberUtils或ConvertUtils里:

public static List<Integer> parseIntList(String[] array, Integer defaultValue) { if (array == null || array.length == 0) { return Collections.emptyList(); } return Arrays.stream(array) .map(String::trim) .filter(s -> !s.isEmpty()) .map(s -> { try { return Integer.parseInt(s); } catch (NumberFormatException e) { return defaultValue; } }) .filter(Objects::nonNull) .collect(Collectors.toList()); }

调用方只需一行:ConvertUtils.parseIntList(config.getValues(), null)。null表示解析失败就跳过。这样把边界处理和异常策略收敛到一处,后续想改成"safely + 默认值"或者"全过滤",只改工具类,不用满项目找调用点。

顺便提一句,filter(Objects::nonNull)和map里的null返回要配合好。我封装时习惯用Integer而非int作为默认值参数,就是想利用null表达"丢弃该元素"的语义。如果默认值传8080这种具体数字,含义就变成"解析失败用8080",语义完全不同,调用方一眼能看懂。

5.2 什么时候不要用Stream转换

Stream不是万金油,有几种情况下我更倾向传统循环:

  • 异常需要精细分级:每个元素的解析可能产生不同类型的异常,且每种类型的处理逻辑不同。Stream的Lambda里嵌套多个catch会把代码弄得很丑,不如for循环直白。
  • 需要在循环中修改外部集合:Stream设计上鼓励无副作用,虽然能用forEach塞进外部List,但代码可读性和并行安全性都下降。
  • 业务逻辑极其复杂:一个元素的处理有十几步、依赖前一步的多个结果、还要互相比较。命令式的for循环配条件和临时变量反而更直观。
  • 懒加载的序列化问题*:Stream只能消费一次,如果业务需要在多个地方复用同一个数据源多次遍历,还是老老实实转成List比较稳。

经验法则:简单映射转换,Stream是正确的默认选择;一旦逻辑复杂到需要大量局部变量和状态,就别为了"帅"硬用Stream。

5.3 代码Review时重点盯哪些点

我总结了几条review清单,检查Stream转换代码时逐条过:

  • 有没有对null输入做保护?数组本身是null会直接NPE。
  • 字符串trim了吗?尤其来自配置文件、Excel的值。
  • 解析失败的兜底策略统一吗?同一种错误处理逻辑别散落多处。
  • 用了parallel吗?如果用了,map里的Lambda是否是纯函数?有没有共享可变状态?
  • 结果List是需要可变还是不可变?别因为Collectors.toList()和Stream.toList()混用引入线上bug。
  • 正则表达式过滤有没有在超大流上使用?有的话换成数值解析try-catch可能更快。

这个清单在团队里推广之后,Stream相关的紧急hotfix肉眼可见变少了。

5.4 从Java 8到Java 17:Stream能力的演进

虽然本文聚焦Java 8,但顺手补一句后续版本的变化,对维护老项目、或者准备升级的朋友有参考价值。Java 9给Stream加了takeWhile、dropWhile、ofNullable;Java 11加了Predicate.not;Java 16给Stream本身加了toList()方法,返回不可变List;Java 17没有新增Stream API,但强化了性能。如果项目升级到Java 16+,collect(Collectors.toList())可以直接替换成stream.toList(),但千万注意不可变性差异——老代码里如果后面有add操作,这一替换就是妥妥的生产事故。

我自己在维护一个Java 8项目,最近规划升级到Java 17,改造清单里专门列了一条:搜索结果里的.collect(Collectors.toList())全部梳理一遍,确认后续没有修改需求才允许替换成.toList()。这种细节不写下来,升级时全靠脑记,极容易漏。

5.5 配合Optional优雅处理可空结果

有时候转换结果可能为空,传统写法是if (list == null || list.isEmpty())判空,Stream配合Optional可以写得更安全:

Optional<List<Integer>> mayList = Optional.ofNullable(raw) .map(s -> s.split(",")) .map(arr -> Arrays.stream(arr) .map(String::trim) .filter(x -> !x.isEmpty()) .map(Integer::parseInt) .collect(Collectors.toList()));

这样调用方可以mayList.ifPresent(list -> ...),或者mayList.orElse(Collections.emptyList()),把空值处理从"约定"变成"类型约束"。很多NPE就是这么被消灭在编译期的。这条经验放在最后说是因为它需要前面对Stream的map链式操作有一定感觉,理解起来才顺。


我个人在实际项目里最深的体会是,Stream不是语法糖的堆砌,它逼着你重新思考"这段逻辑本质上在做什么"。字符串数组转List<Integer>只是入门第一课,把map、filter、collect这三个操作吃透,后面处理对象List的转换、分组、归约都会顺畅很多。别急着炫技上parallel,先把单线程的可读性和健壮性做到位。踩过几次NumberFormatException的坑之后,我现在写转换代码第一反应永远是:这个输入可能是null吗?有空字符串吗?有非法格式吗?把这几个问题想清楚,写出来的代码就离线上事故远了。

返回列表