内容简介:《Java8 Stream编码实战》的代码全部在对于初学者,必须要声明一点的是,Java8中的Stream尽管被称作为“流”,但它和文件流、字符流、字节流我们在使用集合时,最常用的就是迭代。
第三章 Stream流
《Java8 Stream编码实战》的代码全部在 https://github.com/yu-linfeng/BlogRepositories/tree/master/repositories/stream-coding ,一定要配合源码阅读,并且不断加以实践,才能更好的掌握Stream。
对于初学者,必须要声明一点的是,Java8中的Stream尽管被称作为“流”,但它和文件流、字符流、字节流 完全没有任何关系 。Stream流使 程序员 得以站在更高的抽象层次上对集合进行操作。也就是说 Java 8中新引入的Stream流是针对集合的操作。
3.1 迭代
我们在使用集合时,最常用的就是迭代。
public int calcSum(List<Integer> list) { int sum = 0; for (int i = 0; i < list.size(); i++) { sum += list.get(i); } return sum; }
com.coderbuff.chapter3_stream.chapter3_1.ForDemo#calcSum
例如,我们可能会对集合中的元素累加并返回结果。这段代码由于for循环的样板代码并不能很清晰的传达程序员的意图。也就是说,实际上除了方法名叫“计算总和”,程序员必须阅读整个循环体才能理解。你可能觉得一眼就能理解上述代码的意图,但如果碰上下面的代码,你还能一眼理解吗?
public Map<Long, List<Student>> useFor(List<Student> students) { Map<Long, List<Student>> map = new HashMap<>(); for (Student student : students) { List<Student> list = map.get(student.getStudentNumber()); if (list == null) { list = new ArrayList<>(); map.put(student.getStudentNumber(), list); } list.add(student); } return map; }
阅读完这个循环体以及包含的if判断条件,大概可以知道这是想使用“studentNumber”对“Student”对象分组。这段代码在Stream进行重构后,将会变得非常简洁和 易读 。
public Map<Long, List<Student>> useStreamByGroup(List<Student> students) { Map<Long, List<Student>> map = students.stream().collect(Collectors.groupingBy(Student::getStudentNumber)); return map; }
当第一次看到这样的写法时,可能会认为这样的代码可读性不高,不容易测试。我相信,当你在学习掌握Stream后会重新改变对它的看法。
3.2 Stream
3.2.1 创建
要想使用Stream,首先要创建一个流,创建流最常用的方式是直接调用集合的 stream
方法。
/** * 通过集合构造流 */ private void createByCollection() { List<Integer> list = new ArrayList<>(); Stream<Integer> stream = list.stream(); }
com.coderbuff.chapter3_stream.chapter3_2.StreamCreator#createByCollection
也能通过数组构造一个流。
/** * 通过数组构造流 */ private void createByArrays() { Integer[] intArrays = {1, 2, 3}; Stream<Integer> stream = Stream.of(intArrays); Stream<Integer> stream1 = Arrays.stream(intArrays); }
com.coderbuff.chapter3_stream.chapter3_2.StreamCreator#createByArrays
学习Stream流,掌握集合创建流就足够了。
3.2.2 使用
对于Stream流操作共分为两个大类: 惰性求值 、 及时求值 。
所谓惰性求值,指的是操作最终不会产生新的集合。及时求值,指的是操作会产生新的集合。举以下示例加以说明:
/** * 通过for循环过滤元素返回新的集合 * @param list 待过滤的集合 * @return 过滤后的集合 */ private List<Integer> filterByFor(List<Integer> list) { List<Integer> filterList = new ArrayList<>(); for (Integer number : list) { if (number > 1) { filterList.add(number); } } return filterList; }
com.coderbuff.chapter3_stream.chapter3_3.Example#filterByFor
通过for循环过滤元素返回新的集合,这里的“过滤”表示排除不符合条件的元素。我们使用Stream流过滤并返回新的集合:
/** * 通过Stream流过滤元素返回新的集合 * @param list 待过滤的集合 * @return 新的集合 */ private List<Integer> filterByStream(List<Integer> list) { return list.stream() .filter(number -> number > 1) .collect(Collectors.toList()); }
com.coderbuff.chapter3_stream.chapter3_3.Example#filterByStream
Stream操作时,先调用了 filter
方法传入了一个Lambda表达式代表过滤规则,后调用了 collect
方法表示将流转换为List集合。
按照常理来想,一个方法调用完后,接着又调用了一个方法,看起来好像做了两次循环,把问题搞得更复杂了。但实际上,这里的 filter
操作是 惰性求值 ,它并不会返回新的集合,这就是Stream流设计精妙的地方。既能在保证可读性的同时,也能保证性能不会受太大影响。
所以使用Stream流的理想方式就是, 形成一个惰性求值的链,最后用一个及早求值的操作返回想要的结果。
我们不需要去记哪些方法是惰性求值,如果方法的返回值是Stream那么它代表的就是惰性求值。如果返回另外一个值或空,那么它代表的就是及早求值。
3.2.3 常用的Stream操作
map
map操作不好理解,它很容易让人以为这是一个转换为Map数据结构的操作。实际上他是将集合中的元素类型,转换为另外一种数据类型。
例如,你想将“学生”类型的集合转换为只有“学号”类型的集合,应该怎么做?
/** * 通过for循环提取学生学号集合 * @param list 学生对象集合 * @return 学生学号集合 */ public List<Long> fetchStudentNumbersByFor(List<Student> list) { List<Long> numbers = new ArrayList<>(); for (Student student : list) { numbers.add(student.getStudentNumber()); } return numbers; }
com.coderbuff.chapter3_stream.chapter3_4.StreamMapDemo#fetchStudentNumbersByFor
这是只借助JDK的“传统”方式。如果使用Stream则可以直接通过 map
操作来获取只包含学生学号的集合。
/** * 通过Stream map提取学生学号集合 * @param list 学生对象集合 * @return 学生学号集合 */ public List<Long> fetchStudentNumbersByStreamMap(List<Student> list) { return list.stream() .map(Student::getStudentNumber) .collect(Collectors.toList()); }
com.coderbuff.chapter3_stream.chapter3_4.StreamMapDemo#fetchStudentNumbersByStreamMap
map
传入的是一个方法,同样可以理解为传入的是一个“行为”,在这里我们传入方法“getStudentNumber”表示将通过这个方法进行转换分类。
“Student::getStudentNumber”叫 方法引用 ,它是“student -> student.getStudentNumber()”的简写。表示 直接引用已有Java类或对象的方法或构造器 。在这里我们是需要传入“getStudentNumber”方法,在有的地方,你可能会看到这样的代码“Student::new”,new调用的就是构造方法,表示创建一个对象。方法引用,可以将我们的代码变得更加紧凑简洁。
我们再举一个例子,将小写的字符串集合转换为大写字符串集合。
/** * 通过Stream map操作将小写的字符串集合转换为大写 * @param list 小写字符串集合 * @return 大写字符串集合 */ public List<String> toUpperByStreamMap(List<String> list) { return list.stream() .map(String::toUpperCase) .collect(Collectors.toList()); }
com.coderbuff.chapter3_stream.chapter3_4.StreamMapDemo#toUpperByStreamMap
filter
filter
,过滤。这里的过滤含义是“排除不符合某个条件的元素”,也就是返回true的时候保留,返回false排除。
我们仍然以“学生”对象为例,要排除掉分数低于60分的学生。
/** * 通过for循环筛选出分数大于60分的学生集合 * @param students 待过滤的学生集合 * @return 分数大于60分的学生集合 */ public List<Student> fetchPassedStudentsByFor(List<Student> students) { List<Student> passedStudents = new ArrayList<>(); for (Student student : students) { if (student.getScore().compareTo(60.0) >= 0) { passedStudents.add(student); } } return passedStudents; }
com.coderbuff.chapter3_stream.chapter3_4.StreamFilterDemo#fetchPassedStudentsByFor
这是我们通常的实现方式,通过for循环能解决“一切”问题,如果使用Stream filter一行就搞定。
/** * 通过Stream filter筛选出分数大于60分的学生集合 * @param students 待过滤的学生集合 * @return 分数大于60分的学生集合 */ public List<Student> fetchPassedStudentsByStreamFilter(List<Student> students) { return students.stream() .filter(student -> student.getScore().compareTo(60.0) >= 0) .collect(Collectors.toList()); }
com.coderbuff.chapter3_stream.chapter3_4.StreamFilterDemo#fetchPassedStudentsByStreamFilter
sorted
排序,也是日常最常用的操作之一。我们常常会把数据按照修改或者创建时间的倒序、升序排列,这步操作通常会放到 SQL 语句中。但如果实在是遇到要对集合进行 排序 时,我们通常也会使用 Comparator.sort
静态方法进行排序,如果是复杂的对象排序,还需要实现 Comparator
接口。
/** * 通过Collections.sort静态方法 + Comparator匿名内部类对学生成绩进行排序 * @param students 待排序学生集合 * @return 排好序的学生集合 */ private List<Student> sortedByComparator(List<Student> students) { Collections.sort(students, new Comparator<Student>() { @Override public int compare(Student student1, Student student2) { return student1.getScore().compareTo(student2.getScore()); } }); return students; }
com.coderbuff.chapter3_stream.chapter3_4.StreamSortedDemo#sortedByComparator
关于 Comparator
可以查看这篇文章《 似懂非懂的Comparable与Comparator 》。简单来讲,我们需要实现 Compartor
接口的 compare
方法,这个方法有两个参数用于比较,返回1代表前者大于后者,返回0代表前者等于后者,返回-1代表前者小于后者。
当然我们也可以手动实现冒泡算法对学生成绩进行排序,不过这样的代码大多出现在课堂教学中。
/** * 使用冒泡 排序算法 对学生成绩进行排序 * @param students 待排序学生集合 * @return 排好序的学生集合 */ private List<Student> sortedByFor(List<Student> students) { for (int i = 0; i < students.size() - 1; i++) { for (int j = 0; j < students.size() - 1 - i; j++) { if (students.get(j).getScore().compareTo(students.get(j + 1).getScore()) > 0) { Student temp = students.get(j); students.set(j, students.get(j + 1)); students.set(j + 1, temp); } } } return students; }
com.coderbuff.chapter3_stream.chapter3_4.StreamSortedDemo#sortedByFor
在使用Stream sorted后,你会发现代码将变得无比简洁。
/** * 通过Stream sorted对学生成绩进行排序 * @param students 待排序学生集合 * @return 排好序的学生集合 */ private List<Student> sortedByStreamSorted(List<Student> students) { return students.stream() .sorted(Comparator.comparing(Student::getScore)) .collect(Collectors.toList()); }
com.coderbuff.chapter3_stream.chapter3_4.StreamSortedDemo#sortedByStreamSorted
简洁的后果就是,代码变得不那么好读,其实并不是代码的可读性降低了,而只是代码不是按照你的习惯去写的。而大部分人恰好只习惯墨守成规,而不愿意接受新鲜事物。
上面的排序是按照从小到大排序,如果想要从大到小应该如何修改呢?
Compartor.sort
方法和for循环调换if参数的位置即可。
return student1.getScore().compareTo(student2.getScore()); 修改为 return student2.getScore().compareTo(student1.getScore());
if (students.get(j).getScore().compareTo(students.get(j + 1).getScore()) > 0) 修改为 if (students.get(j).getScore().compareTo(students.get(j + 1).getScore()) < 0)
这改动看起来很简单,但如果这是一段 没有注释并且不是你本人写的代码 ,你能一眼知道是按降序还是升序排列吗?你还能说这是可读性强的代码吗?
如果是Stream操作。
return students.stream() .sorted(Comparator.comparing(Student::getScore)) .collect(Collectors.toList()); 修改为 return students.stream() .sorted(Comparator.comparing(Student::getScore).reversed()) .collect(Collectors.toList());
这就是 声明式编程 ,你只管叫它做什么,而不像 命令式编程 叫它如何做。
reduce
reduce
是将传入一组值,根据计算模型输出一个值。例如求一组值的最大值、最小值、和等等。
不过使用和读懂 reduce
还是比较晦涩,如果是简单最大值、最小值、求和计算,Stream已经为我们提供了更简单的方法。如果是复杂的计算,可能为了代码的可读性和维护性还是建议用传统的方式表达。
我们来看几个使用 reduce
进行累加例子。
/** * Optional<T> reduce(BinaryOperator<T> accumulator); * 使用没有初始值对集合中的元素进行累加 * @param numbers 集合元素 * @return 累加结果 */ private Integer calcTotal(List<Integer> numbers) { return numbers.stream() .reduce((total, number) -> total + number).get(); }
com.coderbuff.chapter3_stream.chapter3_4.StreamReduceDemo#calcTotal
reduce
有3个重载方法,
第一个例子调用的是 Optional<T> reduce(BinaryOperator<T> accumulator);
它只有 BinaryOperator
一个参数,这个接口是一个 函数接口 ,代表它可以接收一个Lambda表达式,它继承自 BiFunction
函数接口,在 BiFunction
接口中,只有一个方法:
@FunctionalInterface public interface BiFunction<T, U, R> { R apply(T t, U u); }
这个方法有两个参数。也就是说,传入 reduce
的Lambda表达式需要“实现”这个方法。如果不理解这是什么意思,我们可以抛开Lambda表达式,从纯粹传统的接口角度去理解。
首先, Optional<T> reduce(BinaryOperator<T> accumulator);
方法接收 BinaryOperator
类型的对象,而 BinaryOperator
是一个接口并且继承自 BiFunction
接口,而在 BiFunction
中只有一个方法定义 R apply(T t, U u)
,也就是说我们需要实现 apply
方法。
其次,接口需要被实现,我们不妨传入一个匿名内部类,并且实现 apply
方法。
private Integer calcTotal(List<Integer> numbers) { return numbers.stream() .reduce(new BinaryOperator<Integer>() { @Override public Integer apply(Integer integer, Integer integer2) { return integer + integer2; } }).get(); }
最后,我们在将匿名内部类改写为Lambda风格的代码,箭头左边是参数,右边是函数主体。
private Integer calcTotal(List<Integer> numbers) { return numbers.stream() .reduce((total, number) -> total + number).get(); }
至于为什么两个参数相加最后就是不断累加的结果,这就是 reduce
的内部实现了。
接着看第二个例子:
/** * T reduce(T identity, BinaryOperator<T> accumulator); * 赋初始值为1,对集合中的元素进行累加 * @param numbers 集合元素 * @return 累加结果 */ private Integer calcTotal2(List<Integer> numbers) { return numbers.stream() .reduce(1, (total, number) -> total + number); }
com.coderbuff.chapter3_stream.chapter3_4.StreamReduceDemo#calcTotal2
第二个例子调用的是 reduce
的 T reduce(T identity, BinaryOperator<T> accumulator);
重载方法,相比于第一个例子,它多了一个参数“identity”,这是进行后续计算的初始值, BinaryOperator
和第一个例子一样。
第三个例子稍微复杂一点,前面两个例子集合中的元素都是基本类型,而现实情况是,集合中的参数往往是一个 对象 我们常常需要对对象中的某个字段做累加计算,比如计算学生对象的总成绩。
我们先来看for循环怎么做的:
/** * 通过for循环对集合中的学生成绩字段进行累加 * @param students 学生集合 * @return 分数总和 */ private Double calcTotalScoreByFor(List<Student> students) { double total = 0; for (Student student : students) { total += student.getScore(); } return total; }
com.coderbuff.chapter3_stream.chapter3_4.StreamReduceDemo#calcTotalScoreByFor
要按前文的说法,“这样的代码充斥了样板代码,除了方法名,代码并不能直观的反应程序员的意图,程序员需要读完整个循环体才能理解”,但凡事不是绝对的,如果换做 reduce
操作:
/** * <U> U reduce(U identity, * BiFunction<U, ? super T, U> accumulator, * BinaryOperator<U> combiner); * 集合中的元素是"学生"对象,对学生的"score"分数字段进行累加 * @param students 学生集合 * @return 分数总和 */ private Double calcTotalScoreByStreamReduce(List<Student> students) { return students.stream() .reduce(Double.valueOf(0), (total, student) -> total + student.getScore(), (aDouble, aDouble2) -> aDouble + aDouble2); }
com.coderbuff.chapter3_stream.chapter3_4.StreamReduceDemo#calcTotalScoreByStreamReduce
这样的代码,已经不是样板代码的问题了,是大部分程序员即使读十遍可能也不知道要表达什么含义。但是为了学习Stream我们还是要硬着头皮去理解它。
Lambda表达式不好理解,过于简洁的语法,也代表更少的信息量,我们还是先将Lambda表达式还原成匿名内部类。
private Double calcTotalScoreByStreamReduce(List<Student> students) { return students.stream() .reduce(Double.valueOf(0), new BiFunction<Double, Student, Double>() { @Override public Double apply(Double total, Student student) { return total + student.getScore(); } }, new BinaryOperator<Double>() { @Override public Double apply(Double aDouble, Double aDouble2) { return aDouble + aDouble2; } }); }
reduce
的第三个重载方法 <U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner);
一共有3个参数,与第一、二个重载方法不同的是,第一、第二个重载方法参数和返回类型都是泛型“T”,意思是入参和返回都是同一种数据类型。但在第三个例子中,入参是 Student
对象,返回却是 Double
,显然不能使用第一、二个重载方法。
第三个重载方法的第一个参数类型是泛型“U”,它的返回类型也是泛型“U”,所以第一个参数类型,代表了返回的数据类型,我们必须将第一个类型定义为 Double
, 例子中的入参是 Double.valueOf(0)
表示了累加的初始值为0,且返回值是 Double
类型 。第二个参数可以简单理解为“应该如何计算,累加还是累乘”的计算模型。最难理解的是第三个参数,因为前两个参数类型看起来已经能满足我们的需求,为什么还有第三个参数呢?
当我在第三个参数中加上一句输出时,发现它确实没有用。
private Double calcTotalScoreByStreamReduce(List<Student> students) { return students.stream() .reduce(Double.valueOf(0), new BiFunction<Double, Student, Double>() { @Override public Double apply(Double total, Student student) { return total + student.getScore(); } }, new BinaryOperator<Double>() { @Override public Double apply(Double aDouble, Double aDouble2) { System.out.println("第三个参数的作用"); return aDouble + aDouble2; } }); }
控制台没有输出“第三个参数的作用”,改变它的返回值最终结果也没有任何改变,这的确表示它 真的没有用 。
第三个参数在这里的确没有用,这是因为我们目前所使用的Stream流是串行操作,它在 并行Stream流 中发挥的是 多路合并 的作用,在下一章会继续介绍并行Stream流,这里就不再多做介绍。
对于 reduce
操作,我的个人看法是, 不建议在现实中使用 。如果你有累加、求最大值、最小值的需求,Stream封装了更简单的方法。如果是特殊的计算,不如直接按for循环实现,如果一定要使用Stream对学生成绩求和也不妨换一个思路。
前面提到 map
方法可以将集合中的元素类型转换为另一种类型,那我们就能把学生的集合转换为分数的集合,再调用 reduce
的第一个重载方法计算总和:
/** * 先使用map将学生集合转换为分数的集合 * 再使用reduce调用第一个重载方法计算总和 * @param students 学生集合 * @return 分数总和 */ private Double calcTotalScoreByStreamMapReduce(List<Student> students) { return students.stream() .map(Student::getScore) .reduce((total, score) -> total + score).get(); }
com.coderbuff.chapter3_stream.chapter3_4.StreamReduceDemo#calcTotalScoreByStreamMapReduce
min
min
方法能返回集合中的最小值。它接收一个 Comparator
对象,Java8对 Comparator
接口提供了新的静态方法 comparing
,这个方法返回 Comparator
对象,以前我们需要手动实现 compare
比较,现在我们只需要调用 Comparator.comparing
静态方法即可。
/** * 通过Stream min计算集合中的最小值 * @param numbers 集合 * @return 最小值 */ private Integer minByStreamMin(List<Integer> numbers) { return numbers.stream() .min(Comparator.comparingInt(Integer::intValue)).get(); }
com.coderbuff.chapter3_stream.chapter3_4.StreamMinDemo#minByStreamMin
Comparator.comparingInt
用于比较int类型数据。因为集合中的元素是Integer类型,所以我们传入Integer类型的iniValue方法。如果集合中是对象类型,我们直接调用 Comparator.comparing
即可。
/** * 通过Stream min计算学生集合中的最低成绩 * @param students 学生集合 * @return 最低成绩 */ private Double minScoreByStreamMin(List<Student> students) { Student minScoreStudent = students.stream() .min(Comparator.comparing(Student::getScore)).get(); return minScoreStudent.getScore(); }
com.coderbuff.chapter3_stream.chapter3_4.StreamMinDemo#minScoreByStreamMin
max
和 min
的用法相同,含义相反取最大值。这里不再举例。
summaryStatistics
求和操作也是常用的操作,利用 reduce
会让代码晦涩难懂,特别是复杂的对象类型。
好在Streaam提供了求和计算的简便方法—— summaryStatistics
,这个方法并不是Stream对象提供,而是 IntStream
,可以把它当做处理基本类型的流,同理还有 LongStream
、 DoubleStream
。
summaryStatistics
方法也不光是只能求和,它还能求最小值、最大值。
例如我们求学生成绩的平均分、总分、最高分、最低分。
/** * 学生类型的集合常用计算 * @param students 学生 */ private void calc(List<Student> students) { DoubleSummaryStatistics summaryStatistics = students.stream() .mapToDouble(Student::getScore) .summaryStatistics(); System.out.println("平均分:" + summaryStatistics.getAverage()); System.out.println("总分:" + summaryStatistics.getSum()); System.out.println("最高分:" + summaryStatistics.getMax()); System.out.println("最低分:" + summaryStatistics.getMin()); }
com.coderbuff.chapter3_stream.chapter3_4.StreamSummaryStatisticsDemo#calc
返回的 summaryStatistics
包含了我们想要的所有结果,不需要我们单独计算。 mapToDouble
方法将Stream流按“成绩”字段组合成新的 DoubleStream
流, summaryStatistics
方法返回的 DoubleSummaryStatistics
对象为我们提供了常用的计算。
灵活运用好 summaryStatistics
,一定能给你带来更少的bug和更高效的编码。
3.3 Collectors
前面的大部分操作都是以 collect(Collectors.toList())
结尾,看多了自然也大概猜得到它是将流转换为集合对象。最大的功劳当属Java8新提供的类—— Collectors
收集器。
Collectors
不但有 toList
方法能将流转换为集合,还包括 toMap
转换为Map数据类型,还能 分组 。
/** * 将学生类型的集合转换为只包含名字的集合 * @param students 学生集合 * @return 学生姓名集合 */ private List<String> translateNames(List<Student> students) { return students.stream() .map(Student::getStudentName) .collect(Collectors.toList()); }
com.coderbuff.chapter3_stream.chapter3_4.StreamCollectorsDemo#translateNames
/** * 将学生类型的集合转换为Map类型,key=学号,value=学生 * @param students 学生集合 * @return 学生Map */ private Map<Long, Student> translateStudentMap(List<Student> students) { return students.stream() .collect(Collectors.toMap(Student::getStudentNumber, student -> student)); }
com.coderbuff.chapter3_stream.chapter3_4.StreamCollectorsDemo#translateStudentMap
/** * 按学生的学号对学生集合进行分组返回Map,key=学生学号,value=学生集合 * @param students 学生集合 * @return 按学号分组的Map */ private Map<Long, List<Student>> studentGroupByStudentNumber(List<Student> students) { return students.stream() .collect(Collectors.groupingBy(Student::getStudentNumber)); }
com.coderbuff.chapter3_stream.chapter3_4.StreamCollectorsDemo#studentGroupByStudentNumber
关注公众号( CoderBuff )回复“ stream ”抢先获取PDF完整版。
近期教程:
这是一个能给程序员加buff的公众号 (CoderBuff)
以上所述就是小编给大家介绍的《Java8 Stream流》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- Java8 Stream流
- 《Java8实战》-第四章读书笔记(引入流Stream)
- 《Java8实战》-第五章读书笔记(使用流Stream-01)
- 《Java8实战》-第五章读书笔记(使用流Stream-02)
- [译] 一文带你玩转 Java8 Stream 流,从此操作集合 So Easy
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
形式感+:网页视觉设计创意拓展与快速表现
晋小彦 / 清华大学出版社 / 2014-1-1 / 59.00元
网页设计师从早年的综合性工作中分化出来,形成了相对独立的专业岗位,网页设计也不再是单纯的软件应用,它衍生出了许多独立的研究方向,当网站策划、交互体验都逐渐独立之后,形式感的突破和表现成为网页视觉设计的一项重要工作。随着时代的发展,网页设计更接近于一门艺术。网络带宽和硬件的发展为网页提供了使用更大图片、动画甚至视频的权利,而这些也为视觉设计师提供了更多表现的空间。另外多终端用户屏幕(主要是各种移动设......一起来看看 《形式感+:网页视觉设计创意拓展与快速表现》 这本书的介绍吧!