查看原文
其他

Java 开发者应该改掉的 3 种不良习惯

ImportNew ImportNew 2019-10-04

(给ImportNew加星标,提高Java技能)


翻译:伯乐在线/Mr.Dcheng,校对:AtlantisLee


前言:想改掉一些坏习惯吗?让我们从 null、函数式编程以及 getter 和 setter 着手,看看如何改善代码。


作为 Java 开发人员,我们会使用一些习惯用法,典型的例子,如:返回 null 值、滥用 getter 和 setter,即使在没有必要的情况下也是如此。虽然在某些情况下,这些用法可能是适当的,但通常是习惯使然,或者是我们为了让系统正常工作的权宜之计。在本文中,我们将讨论在 Java 初学者甚至高级开发人员中都常见的三种情况,并探究它们是如何给我们带来麻烦的。应该指出的是,文中总结的规则并不是无论何时都应该始终遵守的硬性要求。有时候,可能有一个很好的理由来使用这些模式解决问题,但是总的来说,还是应该相对的减少这些用法。首先,我们将从 Null 这个关键字开始讨论,它也是 Java 中使用最频繁、但也是最具两面性的关键字之一。


1. Returning Null(返回 Null)


null 一直是开发者最好的朋友,也是最大的敌人,这在 Java 中也不例外。在高性能应用中,使用 null 是一种减少对象数量的可靠方法,它表明方法没有要返回的值。与抛出异常不同,如果要通知客户端不能获取任何值,使用 null 是一种快速且低开销的方法,它不需要捕获整个堆栈跟踪。


在高性能系统的环境之外,null 的存在会导致创建更繁琐的 null 返回值检查,从而破坏应用程序,并在解引用空对象时导致 NullPointerExceptions。在大多数应用程序中,返回 null 有三个主要原因:


  1. 表示列表中找不到元素;

  2. 表示即使没有发生错误,也找不到有效值;

  3. 表示特殊情况下的返回值。


除非有任何性能方面的原因,否则以上每一种情况都有更好的解决方案,它们不使用 null,并且强制开发人员处理出现 null 的情况。更重要的是,这些方法的客户端不会为该方法是否会在某些边缘情况下返回 null 而伤脑筋。在每种情况下,我们将设计一种不返回 null 值的简洁方法。


No Elements(集合中没有元素的情况)


在返回列表或其他集合时,通常会看到返回空集合,以表明无法找到该集合的元素。例如,我们可以创建一个服务来管理数据库中的用户,该服务类似于以下内容(为了简洁起见,省略了一些方法和类定义):


public class UserService {
public List<User> getUsers() {
User[] usersFromDb = getUsersFromDatabase();
if (usersFromDb == null) {
// No users found in database
return null;
}
else {
return Arrays.asList(usersFromDb);
}
}
}
UserServer service = new UserService();
List<Users> users = service.getUsers();
if (users != null) {
for (User user: users) {
System.out.println("User found: " + user.getName());
}
}


因为我们选择在没有用户的情况下返回 null 值,所以我们迫使客户端在遍历用户列表之前先处理这种情况。如果我们返回一个空列表来表示没有找到用户,那么客户端可以完全删除空检查并像往常一样遍历用户。如果没有用户,则隐式跳过循环,而不必手动处理这种情况;从本质上说,循环遍历用户列表的功能就像我们为空列表和填充列表所做的那样,而不需要手动处理任何一种情况:


public class UserService {
public List<User> getUsers() {
User[] usersFromDb = getUsersFromDatabase();
if (usersFromDb == null) {
// No users found in database
return Collections.emptyList();
}
else {
return Arrays.asList(usersFromDb);
}
}
}
UserServer service = new UserService();
List<Users> users = service.getUsers();
for (User user: users) {
System.out.println("User found: " + user.getName());
}


在上面的例子中,我们返回的是一个不可变的空列表。这是一个可接受的解决方案,只要我们记录该列表是不可变的并且不应该被修改(这样做可能会抛出异常)。如果列表必须是可变的,我们可以返回一个空的可变列表,如下例所示:


public List<User> getUsers() {
User[] usersFromDb = getUsersFromDatabase();
if (usersFromDb == null) {
// No users found in database
return new ArrayList<>(); // A mutable list
}
else {
return Arrays.asList(usersFromDb);
}
}


一般来说,当没有发现任何元素的时候,应遵守以下规则:


返回一个空集合(或 list、set、queue 等等)表明找不到元素。


这样做不仅减少了客户端必须执行的特殊情况处理,而且还减少了接口中的不一致性(例如,我们常常返回一个 list 对象,而不是其他对象)。


Optional Value(可选值)


很多时候,我们希望在没有发生错误时通知客户端不存在可选值,此时返回 null。例如,从 web 地址获取参数。在某些情况下,参数可能存在,但在其他情况下,它可能不存在。缺少此参数并不一定表示错误,而是表示用户不需要提供该参数时包含的功能(例如排序)。如果没有参数,则返回 null;如果提供了参数,则返回参数值(为了简洁起见,删除了一些方法):


public class UserListUrl {
private final String url;
public UserListUrl(String url) {
this.url = url;
}
public String getSortingValue() {
if (urlContainsSortParameter(url)) {
return extractSortParameter(url);
}
else {
return null;
}
}
}
UserService userService = new UserService();
UserListUrl url = new UserListUrl("http://localhost/api/v2/users");
String sortingParam = url.getSortingValue();
if (sortingParam != null) {
UserSorter sorter = UserSorter.fromParameter(sortingParam);
return userService.getUsers(sorter);
}
else {
return userService.getUsers();
}


当没有提供参数时,返回 null,客户端必须处理这种情况,但是在 getSortingValue 方法的签名中,没有任何地方声明排序值是可选的。如果方法的参数是可选的,并且在没有参数时,可能返回 null,要知道这个事实,我们必须阅读与该方法相关的文档(如果提供了文档)。


相反,我们可以使可选性显式地返回一个 Optional 对象。正如我们将看到的,当没有参数存在时,客户端仍然需要处理这种情况,但是现在这个需求已经明确了。更重要的是,Optional 类提供了比简单的 null 检查更多的机制来处理丢失的参数。例如,我们可以使用 Optional 类提供的查询方法(一种状态测试方法)简单地检查参数是否存在:


public class UserListUrl {
private final String url;
public UserListUrl(String url) {
this.url = url;
}
public Optional<String> getSortingValue() {
if (urlContainsSortParameter(url)) {
return Optional.of(extractSortParameter(url));
}
else {
return Optional.empty();
}
}
}
UserService userService = new UserService();
UserListUrl url = new UserListUrl("http://localhost/api/v2/users");
Optional<String> sortingParam = url.getSortingValue();
if (sortingParam.isPresent()) {
UserSorter sorter = UserSorter.fromParameter(sortingParam.get());
return userService.getUsers(sorter);
}
else {
return userService.getUsers();
}


这与「空检查」的情况几乎相同,但是我们已经明确了参数的可选性(即客户机在不调用 get() 的情况下无法访问参数,如果可选参数为空,则会抛出NoSuchElementException)。如果我们不希望根据 web 地址中的可选参数返回用户列表,而是以某种方式使用该参数,我们可以使用 ifPresentOrElse 方法来这样做:


sortingParam.ifPresentOrElse(
param -> System.out.println("Parameter is :" + param),
() -> System.out.println("No parameter supplied.")
);


这极大降低了「空检查」的影响。如果我们希望在没有提供参数时忽略参数,可以使用 ifPresent 方法:


sortingParam.ifPresent(param -> System.out.println("Parameter is :" + param));


在这两种情况下,使用 Optional 对象要优于返回 null 以及显式地强制客户端处理返回值可能不存在的情况,为处理这个可选值提供了更多的途径。考虑到这一点,我们可以制定以下规则:


如果返回值是可选的,则通过返回一个 Optional 来确保客户端处理这种情况,该可选的值在找到值时包含一个值,在找不到值时为空


Special-Case Value(特殊情况值)


最后一个常见用例是特殊用例,在这种情况下无法获得正常值,客户端应该处理与其他用例不同的极端情况。例如,假设我们有一个命令工厂,客户端定期从命令工厂请求命令。如果没有命令可以获得,客户端应该等待 1 秒钟再请求。我们可以通过返回一个空命令来实现这一点,客户端必须处理这个空命令,如下面的例子所示(为了简洁起见,没有显示一些方法):


public interface Command {
public void execute();
}
public class ReadCommand implements Command {
@Override
public void execute()
{
System.out.println("Read");
}
}
public class WriteCommand implements Command {
@Override
public void execute()
{
System.out.println("Write");
}
}
public class CommandFactory {
public Command getCommand() {
if (shouldRead()) {
return new ReadCommand();
}
else if (shouldWrite()) {
return new WriteCommand();
}
else {
return null;
}
}
}
CommandFactory factory = new CommandFactory();
while (true) {
Command command = factory.getCommand();
if (command != null) {
command.execute();
}
else {
Thread.sleep(1000);
}
}


由于 CommandFactory 可以返回空命令,客户端有义务检查接收到的命令是否为空,如果为空,则休眠1秒。这将创建一组必须由客户端自行处理的条件逻辑。我们可以通过创建一个「空对象」(有时称为特殊情况对象)来减少这种开销。「空对象」将在 null 场景中执行的逻辑(休眠 1 秒)封装到 null 情况下返回的对象中。对于我们的命令示例,这意味着创建一个在执行时休眠的 SleepCommand


public class SleepCommand implements Command {
@Override
public void execute()
{
Thread.sleep(1000);
}
}
public class CommandFactory {
public Command getCommand() {
if (shouldRead()) {
return new ReadCommand();
}
else if (shouldWrite()) {
return new WriteCommand();
}
else {
return new SleepCommand();
}
}
}
CommandFactory factory = new CommandFactory();
while (true) {
Command command = factory.getCommand();
command.execute();
}


与返回空集合的情况一样,创建「空对象」允许客户端隐式处理特殊情况,就像它们是正常情况一样。但这并不总是可行的;在某些情况下,处理特殊情况的决定必须由客户做出。这可以通过允许客户端提供默认值来处理,就像使用 Optional 类一样。在 Optional 的情况下,客户端可以使用 orElse 方法获取包含的值或默认值:


UserListUrl url = new UserListUrl("http://localhost/api/v2/users");
Optional<String> sortingParam = url.getSortingValue();
String sort = sortingParam.orElse("ASC");


如果有一个提供的排序参数(例如,如果 Optional 包含一个值),这个值将被返回。如果不存在值,默认情况下将返回「ASC」。Optional 类还允许客户端在需要时创建默认值,以防默认创建过程开销较大(即只在需要时创建默认值):


UserListUrl url = new UserListUrl("http://localhost/api/v2/users");
Optional<String> sortingParam = url.getSortingValue();
String sort = sortingParam.orElseGet(() -> {
// Expensive computation
});


结合「空对象」和默认值的用法,我们可以设计以下规则:


如果可能,使用「空对象」处理使用 null 关键字的情况,或者允许客户端提供默认值


2. Defaulting to Functional Programming(默认使用函数式编程)


自从在 JDK 8 中引入了 stream 和 lambda 表达式之后,就出现了向函数式编程迁移的趋势,这理当如此。在 lambda 表达式和 stream 出现之前,执行函数式任务是非常麻烦的,并且会导致代码可读性的严重下降。例如,如下代码用传统方式过滤一个集合:


public class Foo {
private final int value;
public Foo(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}
Iterator<Foo> iterator = foos.iterator();
while(iterator.hasNext()) {
if (iterator.next().getValue() > 10) {
iterator.remove();
}
}


虽然这段代码很紧凑,但它并没有以一种明显的方式告诉我们,当满足某个条件时,我们将尝试删除集合的元素。相反,它告诉我们,当集合中有更多的元素时将遍历集合,并将删除值大于 10 的元素(我们可以假设正在进行筛选,但是删除元素的部分被代码的冗长所掩盖)。我们可以使用函数式编程将这个逻辑压缩为一条语句:


foos.removeIf(foo -> foo.getValue() > 10);


这个语句不仅比迭代方式更简洁,而且准确的告诉我们它的行为。如果我们为 predicate 命名并将其传递给 removeIf 方法,甚至可以使其更具可读性:


Predicate<Foo> valueGreaterThan10 = foo -> foo.getValue() > 10;
foos.removeIf(valueGreaterThan10);


这段代码的最后一行读起来像一个英语句子,准确地告诉我们语句在做什么。对于看起来如此紧凑和极具可读性的代码,在任何需要迭代的情况下尝试使用函数式编程是很让人向往的,但这是一种天真的想法。并不是每种情况都适合函数式编程。例如,如果我们尝试在一副牌中打印一组花色和牌面大小的排列组合(花色和牌面大小的每一种组合),我们可以创建以下内容(参见《Effective Java, 3rd Edition》获得这个示例的详细内容):


public static enum Suit {
CLUB, DIAMOND, HEART, SPADE;
}
public static enum Rank {
ONE, TWO, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, NINE, TEN, JACK, QUEEN, KING;
}
Collection<Suit> suits = EnumSet.allOf(Suit.class);
Collection<Rank> ranks = EnumSet.allOf(Rank.class);
suits.stream()
.forEach(suit -> {
ranks.stream().forEach(rank -> System.out.println("Suit: " + suit + ", rank: " + rank));
});


虽然读起来并不复杂,但这种实现并不是最简单的。很明显,我们正试图强行使用 stream,而此时使用传统迭代明显更有利。如果我们使用传统的迭代方法,我们可以将 花色和等级的排列组合简化为:


for (Suit suit: suits) {
for (Rank rank: ranks) {
System.out.println("Suit: " + suit + ", rank: " + rank);
}
}


这种风格虽然不那么浮华,但却直截了当得多。我们可以很快地理解,我们试图遍历每个花色和等级,并将每个等级与每个花色配对。stream 表达式越大,函数式编程的乏味性就越明显。以 Joshua Bloch 《Effective Java, 3rd Edition》第 205 页,第 45 项中创建的以下代码片段为例,在用户提供的路径上查找字典中包含的指定长度内的所有词组:


public class Anagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(
groupingBy(word -> word.chars().sorted()
.collect(StringBuilder::new,
(sb, c) -> sb.append((char) c),
StringBuilder::append).toString()))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.map(group -> group.size() + ": " + group)
.forEach(System.out::println);
}
}
}


即使是经验最丰富的 stream 使用者也可能会对这个实现感到迷茫。短时间内很难理解代码的意图,需要大量的思考才能发现上面的 stream 操作试图实现什么。这并不意味着 stream 一定很复杂或太冗长,只是因为它们不总是最好的选择。正如我们在上面看到的,使用 removeIf 可以将一组复杂的语句简化为一个易于理解的语句。因此,我们不应该试图用 stream 甚至 lambda 表达式替换传统迭代的每个使用场景。相反,在决定是使用函数式编程还是使用传统方式时,我们应该遵循以下规则:


函数式编程和传统的迭代都有其优点和缺点:应该以简易性和可读性为准来选择


尽管在每个可能的场景中使用 Java 最炫、最新的特性可能很让人向往,但这并不总是最好的方法。有时候,老式的功能效果反而最好。


3. Creating Indiscriminate Getters and Setters(滥用 getter 和 setter)


新手程序员学到的第一件事是将与类相关的数据封装在私有字段中,并通过公共方法暴露它们。在实际使用时,通过创建 getter 来访问类的私有数据,创建 setter 来修改类的私有数据:


public class Foo {
private int value;
public void setValue(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}


虽然这对于新程序员来说是一个很好的学习实践,但这种做法不能未经思索就应用在中级或高级编程。在实际中通常发生的情况是,每个私有字段都有一对 getter 和 setter 将类的内部内容暴露给外部实体。这会导致一些严重的问题,特别是在私有字段是可变的情况下。这不仅是 setter 的问题,甚至在只有 getter 时也是如此。以下面的类为例,该类使用 getter 公开其唯一的字段:


public class Bar {
private Foo foo;
public Bar(Foo foo) {
this.foo = foo;
}
public Foo getFoo() {
return foo;
}
}


由于我们删除了 setter 方法,这么做可能看起来明智且无害,但并非如此。假设另一个类访问 Bar 类型的对象,并在 Bar 对象不知道的情况下更改 Foo 的底层值:


Foo foo = new Foo();
Bar bar = new Bar(foo);
// Another place in the code
bar.getFoo().setValue(-1);


在本例中,我们更改了 Foo 对象的底层值,而没有通知 Bar 对象。如果我们提供的 Foo 对象的值破坏了 Bar 对象的一个不变量,这可能会导致一些严重的问题。举个例子,如果我们有一个不变量,它表示 Foo 的值不可能是负的,那么上面的代码片段将在不通知 Bar 对象的情况下静默修改这个不变量。当 Bar 对象使用它的 Foo 对象值时,事情可能会迅速向不好的方向发展,尤其是如果 Bar 对象假设这是不变的,因为它没有暴露 setter 直接重新分配它所保存的 Foo 对象。如果数据被严重更改,这甚至会导致系统失败,如下面例子所示,数组的底层数据在无意中暴露:


public class ArrayReader {
private String[] array;
public String[] getArray() {
return array;
}
public void setArray(String[] array) {
this.array = array;
}
public void read() {
for (String e: array) {
System.out.println(e);
}
}
}

public class Reader {
private ArrayReader arrayReader;
public Reader(ArrayReader arrayReader) {
this.arrayReader = arrayReader;
}
public ArrayReader getArrayReader() {
return arrayReader;
}
public void read() {
arrayReader.read();
}
}

ArrayReader arrayReader = new ArrayReader();
arrayReader.setArray(new String[] {"hello", "world"});
Reader reader = new Reader(arrayReader);
reader.getArrayReader().setArray(null);
reader.read();


执行此代码将导致 NullPointerException,因为当 ArrayReader 的实例对象试图遍历数组时,与该对象关联的数组为 null。这个 NullPointerException 的令人不安之处在于,它可能在对 ArrayReader 进行更改很久之后才发生,甚至可能发生在完全不同的场景中(例如在代码的不同部分中,甚至在不同的线程中),这使得调试变得非常困难。


读者如果仔细考虑,可能还会注意到,我们可以将私有的 ArrayReader 字段设置为 final,因为我们在通过构造函数赋值之后,没有对它重新赋值的方法。虽然这看起来会使 ArrayReader 成为常量,确保我们返回的 ArrayReader 对象不会被更改,但事实并非如此。如果将 final 添加到字段中只能确保字段本身没有重新赋值(即,不能为该字段创建 setter)而不会阻止对象本身的状态被更改。或者我们试图将 final 添加到 getter 方法中,这也是徒劳的,因为方法上的 final 修饰符只意味着该方法不能被子类重写。


我们甚至可以更进一步考虑,在 Reader 的构造函数中防御性地复制 ArrayReader 对象,确保在将对象提供给 Reader 对象之后,传入该对象的对象不会被篡改。例如,应避免以下情况发生:


ArrayReader arrayReader = new ArrayReader();
arrayReader.setArray(new String[] {"hello", "world"});
Reader reader = new Reader(arrayReader);
arrayReader.setArray(null); // Change arrayReader after supplying it to Reader
reader.read(); // NullPointerException thrown


即使有了这三个更改(字段上增加 final 修饰符、getter 上增加 final 修饰符以及提供给构造函数的 ArrayReader 的防御性副本),我们仍然没有解决问题。问题不在于我们暴露底层数据的方式,而是因为我们是在一开始就是错的。要解决这个问题,我们必须停止公开类的内部数据,而是提供一种方法来更改底层数据,同时仍然遵循类不变量。下面的代码解决了这个问题,同时引入了提供的 ArrayReader 的防御性副本,并将 ArrayReader 字段标记为 final,因为没有 setter,所以应该是这样:


译注:原文的如下代码有一处错误,Reader 类中的 setArrayReaderArray 方法返回值类型应为 void,该方法是为了取代 setter,不应产生返回值。


public class ArrayReader {
public static ArrayReader copy(ArrayReader other) {
ArrayReader copy = new ArrayReader();
String[] originalArray = other.getArray();
copy.setArray(Arrays.copyOf(originalArray, originalArray.length));
return copy;
}
// ... Existing class ...
}

public class Reader {
private final ArrayReader arrayReader;
public Reader(ArrayReader arrayReader) {
this.arrayReader = ArrayReader.copy(arrayReader);
}
public ArrayReader setArrayReaderArray(String[] array) {
arrayReader.setArray(Objects.requireNonNull(array));
}
public void read() {
arrayReader.read();
}
}

ArrayReader arrayReader = new ArrayReader();
arrayReader.setArray(new String[] {"hello", "world"});
Reader reader = new Reader(arrayReader);
reader.read();
Reader flawedReader = new Reader(arrayReader);
flawedReader.setArrayReaderArray(null); // NullPointerException thrown


如果我们查看这个有缺陷的读取器,它仍然会抛出 NullPointerException,但在不变量(读取时使用非空数组)被破坏时,会立即抛出该异常,而不是在稍后的某个时间。这确保了不变量的快速失效,这使得调试和找到问题的根源变得容易得多。


我们可以进一步利用这一原则。如果不迫切需要更改类的状态,那么让类的字段完全不可访问是一个好主意。例如,我们可以删除所有能够修改 Reader 类实例对象状态的方法,实现 Reader 类的完全封装:


public class Reader {
private final ArrayReader arrayReader;
public Reader(ArrayReader arrayReader) {
this.arrayReader = ArrayReader.copy(arrayReader);
}
public void read() {
arrayReader.read();
}
}

ArrayReader arrayReader = new ArrayReader();
arrayReader.setArray(new String[] {"hello", "world"});
Reader reader = new Reader(arrayReader);
// No changes can be made to the Reader after instantiation
reader.read();


从逻辑上总结这个概念,如果可能的话,让类不可变是一个好主意。因此,在实例化对象之后,对象的状态永远不会改变。例如,我们可以创建一个不可变的 Car 对象如下:


public class Car {
private final String make;
private final String model;
public Car(String make, String model) {
this.make = make;
this.model = model;
}
public String getMake() {
return make;
}
public String getModel() {
return model;
}
}


需要注意的是,如果类的字段不是基本数据类型,客户端可以如前所述那样修改底层对象。因此,不可变对象应该返回这些对象的防御性副本,不允许客户端修改不可变对象的内部状态。但是请注意,防御性复制会降低性能,因为每次调用 getter 时都会创建一个新对象。对于这个缺陷,不应该过早地进行优化(忽视不可变性,以保证可能的性能提高),但是应该注意到这一点。下面的代码片段提供了一个方法返回值的防御性复制示例:


public class Transmission {
private String type;
public static Transmission copy(Transmission other) {
Transmission copy = new Transmission();
copy.setType(other.getType);
return copy;
}
public String setType(String type) {
this.type = type;
}
public String getType() {
return type;
}
}
public class Car {
private final String make;
private final String model;
private final Transmission transmission;
public Car(String make, String model, Transmission transmission) {
this.make = make;
this.model = model;
this.transmission = Transmission.copy(transmission);
}
public String getMake() {
return make;
}
public String getModel() {
return model;
}
public Transmission getTransmission() {
return Transmission.copy(transmission);
}
}


这给我们提示了以下原则:


使类不可变,除非迫切需要更改类的状态。不可变类的所有字段都应该标记为 private 和 final,以确保不会对字段执行重新赋值,也不会对字段的内部状态提供间接访问


不变性还带来了一些非常重要的优点,例如类能够在多线程上下文中轻松使用(即两个线程可以共享对象,而不用担心一个线程会在另一个线程访问该状态时更改该对象的状态)。总的来说,在很多实际情况下我们可以创建不可变的类,要比我们意识到的要多很多,只是我们习惯了添加了 getter 或 setter。


Conclusion(结论)


我们创建的许多应用程序最终都能正常工作,但是在大量应用程序中,我们无意引入的一些问题可能只会在最极端的情况下出现。在某些情况下,我们做事情是出于方便,甚至是出于习惯,而很少注意这些习惯在我们使用的场景中是否实用(或安全)。在本文中,我们深入研究了在实际应用中最常见的三种问题,如:空返回值、函数式编程的魅力、草率的 getter 和 setter,以及一些实用的替代方法。虽然本文中的规则不是绝对的,但是它们确实为一些在实际应用中遇到的罕见问题提供了见解,并可能有助于在今后避开一些费劲的问题。


推荐阅读

(点击标题可跳转阅读)

Spring 源码解析- Scopes 之 Request 、Session 、Application

在 Java 中应用骨架实现

在 JVM 中使用透明巨型页


看完本文有收获?请转发分享给更多人

关注「ImportNew」,提升Java技能

喜欢就点一下「好看」呗~

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存