MapReduce框架原理--Shuffle机制
Shuffle机制
Mapreduce确保每个reducer的输入都是按键排序的。系统执行排序的过程(Map方法之后,Reduce方法之前的数据处理过程)称之为Shuffle。

partition分区
Partition分区流程处于Mapper数据属于初到环形缓冲区时进行,此时会将通过Partition分区获取到的每一行key-value对应的分区值计入环形缓冲流的左。
问题引出
要求将统计结果按照条件输出到不同文件中(分区)。比如:将统计结果按照手机归属地不同省份输出到不同文件中(分区)
分区可以实现将Map阶段处理的数据在向环形缓冲区写入的时候是以"分类"的方式写的。"一般情况下",MR程序分区数有多少,ReduceTask的数量就应该有多少,可以实现一个分区的数据一个ReduceTask去处理。ReduceTask处理完成之后都会去生成一个结果文件
以WordCount为例,设置ReduceTask的数量
job.setNumReduceTasks(2);

MapReduce底层默认分区机制
默认使用的是HashPartitioner分区机制
@Public
@Stable
public class HashPartitioner<K, V> extends Partitioner<K, V> {
public HashPartitioner() {
} public int getPartition(K key, V value, int numReduceTasks) {
return (key.hashCode() & 2147483647) % numReduceTasks; // 2147483647是int类型的最大值,numReduceTask即是上面设置的ReduceTask的值
}
}
这种分区机制是不可控的,因为它是根据Map阶段获取的key的hashCode值和numReduceTask取余得来的,但是key的hashCode值不确定,所以把key-value数据分到哪一个区我们是不确定的。比如会出现不同的key的HashCode值一致,导致结果输出的不可控制。因此我们在去定义分区的时候我们最常用的方法就是:自定义分区机制
自定义Partition(案例)
自定义Partition步骤:
- 继承Partitioner类:在继承Partitioner类时,应该传递一个<key, value>的泛型,它代表的是需要进行分区的数据,所以需要传递的是map阶段输出的key-value类型,因为分区是在Map阶段执行结束之后往ReduceTask阶段在输出数据时执行的
- 重写里面的getPartition方法,返回值是一个int类型,返回值就是我们的分区。执行逻辑返回一个从0开始的一个连续型数字。比如getPartition返回的取值有0 1 2 3 4时就代表有5个分区,0是1号分区,对应的文件是part-r-00000文件,以此类推。
要求:
a) 返回的分区数字最好是连续的,比如返回了 0 2 3 4 ,数字不连续,不可行
b) 一般情况下,有几个分区,就在Driver中指定numReduceTasks就有几个,不能多写也不能少写
public int getPartition(Text key, Text value, int numReduceTask)方法有三个参数,返回一个int类型的值,它们代表的含义分别是:
@param key:Map阶段输出的key值
@param value:Map阶段输出的value值
@param numReduceTask:定义的ReduceTask的任务数,默认是1
@return 数字,代表的是我要将这条key-value数据输送到哪个分区
3. 在Driver类中指定分区所在的类与分区数量
// 定义不使用默认的HashPartitioner分区,而是使用自定义的分区
job.setPartitionerClass(PhoneDataPartition.class);
// 指定你的ReduceTask必须是5
job.setNumReduceTasks(5);
这里要求ReduceTask的数量必须和自定义的Partition类中设置的分区数量保持一致,原因就是在MR程序中一般默认情况下是一个分区要有一个ReduceTask专门去处理。但是在有些情况下,ReduceTask可能少写或者多写,这样会出一些奇怪的问题。
- 假设Mapper阶段输出的分区是5个,但是设置了1个ReduceTask任务去执行,程序可以运行成功,此时在HDFS中生成1个结果文件。虽然有多个分区,但是一个ReduceTask可以处理这多个分区。
- 但是假设Mapper阶段输出的分区是5个,ReduceTask的数量少于5个,这时程序不能运行,且程序报IO异常的错误。(不患寡而患不均)
java.lang.Exception: java.io.IOException: Illegal partition for 138 (2)
(这里可以利用打工人来举例,假设有五个人一起去打工,但是此时岗位只有一个,那么这五个人可以一起做着一份工作。但是如果设置了两个岗位,分配时将会出现矛盾状况,比如两个工人都想做一份工作,或者两个工人都不想做这一个工作,就会产生冲突)
3. 假设分区有5个,ReduceTask的数量也有5个,那么百分之百可以正常运行,这也是最佳状态/最理想状态。(因为每个人都分配到了自己想做的工作)
4. 假设分区有5个,ReduceTask的数量多于5个,程序也是百分之百可以正常运行,但是会多出一个空白结果文件
【注意】以后在工作中写的ReduceTask的数量最好和分区的数量保持一致,这样的话才能保证处理出的MR程序处于最佳状态
案例一:电话归属地
需求:将统计结果按照手机归属地不同省份输出到不同文件中(分区)
分区:
136----分区1
137----分区2
138----分区3
139----分区4
其他---分区5
默认分区机制:5个分区,需要设置5个ReduceTask,同时默认分区机制是按照key的HashCode值分配的
- PhoneDataMapper.java
public class PhoneDataMapper extends Mapper<LongWritable, Text, Text, Text> {
@Override
protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
String line = value.toString();
String[] fields = line.split("\t");
String phone = fields[1];
// 拿到手机号的前三位
String phoneThree = phone.substring(0, 3);
context.write(new Text(phoneThree), value);
}
}
- PhoneDatePartition.java
public class PhoneDataPartition extends Partitioner<Text, Text> {
@Override
public int getPartition(Text key, Text value, int numReduceTask) {
String s = key.toString();
switch (s) {
case "136":
return 0;
case "137":
return 1;
case "138":
return 2;
case "139":
return 3;
default:
return 4;
}
}
} - PhoneDataReducer.java
public class PhoneDataReducer extends Reducer<Text, Text, NullWritable, Text> {
@Override
protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException {
for (Text value : values) {
context.write(NullWritable.get(), new Text(value));
}
}
} - PhoneDateDriver.java
public class PhoneDataReducer extends Reducer<Text, Text, NullWritable, Text> {
@Override
protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException {
for (Text value : values) {
context.write(NullWritable.get(), new Text(value));
}
}
}
运行截图

案例二:单词计数
需求:按照单词首字母的ASCII码进行奇偶分区(Partitioner)

- WordCountPartition.java
public class WordCountPartition extends Partitioner<Text, LongWritable> {
@Override
public int getPartition(Text text, LongWritable longWritable, int i) {
String word = text.toString();
char first = word.charAt(0);
if (first % 2 == 0 ) {
return 0;
} else {
return 1;
}
}
}

举一反三
单词计数案例:要求根据单词首字母的大小写分区,如果单词首字母是大写,那么单词输出在一个文件中,如果单词的首字母是小写,那么单词输出在另一个分区中
源代码:
public class OtherPartition extends Partitioner<Text, LongWritable> {
@Override
public int getPartition(Text text, LongWritable longWritable, int i) {
String word = text.toString();
char first = word.charAt(0);
if (Character.isUpperCase(first)) {
return 1;
} else {
return 0;
}
}
}
运行结果:

WritableComparable排序
排序概述
- 排序是MapReduce框架中最重要的操作之一。
- MapTask和ReduceTask均会对数据按照key进行排序。该操作属于Hadoop的默认行为。任何应用程序中的数据均会被排序,而不管逻辑上是否被需要。
- 默认排序是按照字典顺序排序,且实现该排序的方法是快速排序。
- 对于MapTask,他会将处理的结果暂时放到环形缓冲区中,当环形缓冲区使用率达到一定阈值后,再对缓冲区中的数据进行一次快速排序,并将这些有序数据溢出到磁盘上,而当这些数据处理完毕后,他会对磁盘上的所有文件进行归并排序。
- 对于ReduceTask,他从每个MapTask上远程拷贝相应的数据文件,如果文件大小超过阈值,则溢写到磁盘上,否则存储在内存中。如果磁盘上文件数目达到一定阈值,则进行一次归并排序以生成一个更大的文件、如果内存最终文件大小或者数目超过一定阈值,则进行一次合并后将数据溢写到磁盘上。当所有数据拷贝完成后,ReduceTask统一对内存和磁盘上的所有数据进行一次归并排序
【问题】在一次MR程序中需要进行几次排序?在什么时候进行排序?
【解答】会进行三次排序。第一次是在环形缓冲区使用率达到一定阈值(80%)时,会对缓冲区中的数据进行一次快速排序;第二次是当数据处理完毕时,会对磁盘上的所有文件进行归并排序;第三次是ReduceTask统一对内存和磁盘上的所有数据进行一次归并排序
排序的分类

部分排序与全排序的区别:全排序是一个结果文件,部分排序是多个结果文件。在实际编程中也就是添加了自定义排序和设置NumReduceTask()
要想实现使自己的数据充当key值进行排序,必须实现WritableComparable接口的方法。
全排序案例
- 需求:
将单词计数后的结果按照数量升序排序
- 源代码:
- WordCountBean.java
public class WordCountBean implements WritableComparable<WordCountBean> {
private String word;
private int count; public String getWord() {
return word;
} public void setWord(String word) {
this.word = word;
} public int getCount() {
return count;
} public void setCount(int count) {
this.count = count;
} @Override
public String toString() {
return word + " " + count;
} /**
* 比较器默认大于或者小于时返回一个非零的数,如果等于则返回0,代表两个对象一模一样
* MR程序比较大小的时候,千万不能返回0.如果返回0,代表两个对象一样,那么相当于认为key是一样的,此时其余的相等数据将显示不出来
* @param o
* @return
*/
@Override
public int compareTo(WordCountBean o) {
if (this.count > o.getCount()) {
return 1;
} else {
return -1;
}
} @Override
public void write(DataOutput dataOutput) throws IOException {
dataOutput.writeUTF(this.word);
dataOutput.writeInt(this.count);
} @Override
public void readFields(DataInput dataInput) throws IOException {
this.word = dataInput.readUTF();
this.count = dataInput.readInt();
}
}
- WordCountBean.java
- 运行截图

部分排序案例
部分排序只是相对于全排序多了自定义分区以及设置ReduceTask的数量
- 需求
根据手机号码前三位进行分区,前三位为"135",则位于第一分区;前三位为"136",则位于第二分区;前三位为"137",则位于第三分区,其余数据在第四分区,并且按照总流量数升序排列
- 源代码
- FlowBean.java
public class FlowBean implements WritableComparable<FlowBean> {
private String phone;
private int upFlow;
private int downFlow;
private int sumFlow; public String getPhone() {
return phone;
} public void setPhone(String phone) {
this.phone = phone;
} public int getUpFlow() {
return upFlow;
} public void setUpFlow(int upFlow) {
this.upFlow = upFlow;
} public int getDownFlow() {
return downFlow;
} public void setDownFlow(int downFlow) {
this.downFlow = downFlow;
} public int getSumFlow() {
return sumFlow;
} public void setSumFlow(int sumFlow) {
this.sumFlow = sumFlow;
} @Override
public String toString() {
return phone + " " + upFlow + " " + downFlow + " " + sumFlow;
} /**
* 序列化方法:将Java对象的属性值怎么序列化写出
* @param dataOutput
* @throws IOException
*/
@Override
public void write(DataOutput dataOutput) throws IOException {
// 将一个String类型的属性序列化写出成二进制数据
dataOutput.writeUTF(this.phone);
// 将一个String类型的属性序列化写出成二进制数据
dataOutput.writeInt(upFlow);
dataOutput.writeInt(downFlow);
dataOutput.writeInt(sumFlow);
} /**
* 反序列化方法:怎么将二进制代码转成JavaBean对象属性的值
* 反序列化的时候,读取二进制数据时,不能随便读
* 序列化写出时先写出哪个属性的值,就先读哪个属性值
* @param dataInput
* @throws IOException
*/
@Override
public void readFields(DataInput dataInput) throws IOException {
this.phone = dataInput.readUTF();
this.upFlow = dataInput.readInt();
this.downFlow = dataInput.readInt();
this.sumFlow = dataInput.readInt();
} /**
* 传进来一个对象,我们需要在这个方法中定义我和传进来的这个对象谁大谁小的逻辑
* 一般情况下,JavaBean对象去比较大小都是比较属性值的大小
* 如果返回的是1,那么代表大于传进来的对象,升序排序
* 如果返回的是0.那么代表相等
* 返回的是-1,代表小于传进来的对象,降序排序
*
* @param o
* @return
*/
@Override
public int compareTo(FlowBean o) {
return Integer.compare(this.sumFlow, o.getSumFlow());
}
} - FlowPartition.java
public class FlowPartition extends Partitioner<FlowBean, NullWritable> {
@Override
public int getPartition(FlowBean flowBean, NullWritable nullWritable, int i) {
String phone = flowBean.getPhone();
String three = phone.substring(0, 3);
switch (three) {
case "135":
return 0;
case "136":
return 1;
case "137":
return 2;
default:
return 3;
}
}
}
- FlowBean.java
3. 运行截图

二次排序案例
- 需求
学生成绩排序:先根据语文成绩降序排序,如果语文成绩相同,再根据英语成绩降序排序
- 源代码
RecordBean.java
public class RecordBean implements WritableComparable<RecordBean> {
private String name;
private int china;
private int maths;
private int english; public String getName() {
return name;
} public void setName(String name) {
this.name = name;
} public int getChina() {
return china;
} public void setChina(int china) {
this.china = china;
} public int getMaths() {
return maths;
} public void setMaths(int maths) {
this.maths = maths;
} public int getEnglish() {
return english;
} public void setEnglish(int english) {
this.english = english;
} @Override
public String toString() {
return "name='" + name + '\'' +
", china=" + china +
", maths=" + maths +
", english=" + english;
} @Override
public void write(DataOutput dataOutput) throws IOException {
dataOutput.writeUTF(this.name);
dataOutput.writeInt(china);
dataOutput.writeInt(maths);
dataOutput.writeInt(english);
} @Override
public void readFields(DataInput dataInput) throws IOException {
this.name = dataInput.readUTF();
this.china = dataInput.readInt();
this.maths = dataInput.readInt();
this.english = dataInput.readInt();
} @Override
public int compareTo(RecordBean o) {
if (this.china > o.getChina()) {
return -1;
}else if(this.china == o.getChina()) {
if (this.english > o.getEnglish()) {
return -1;
} else {
return 1;
}
} else {
return 1;
}
}
} - 运行截图

分组辅助排序
Combiner合并
Combiner基本概念

Combiner合并其实是MapReduce程序中的一个调优策略。
如果多个 MapTask对应一个ReduceTask任务时,ReduceTask的压力会很大,这时可以将ReduceTask的数量增加,这样每个ReduceTask只需要处理一个MapTask,压力会小很多。
还有一种方式就是在Mapper阶段设置一个Combiner合并操作,每一个Combiner对应一个MapTask任务,它会将MapTask任务先进行一次合并,之后再交由ReduceTask执行。
Combiner是可选操作,他只是MR程序中的一个调优策略。但是分区和排序是必选的
Combiner处于Map阶段执行之后,Reduce阶段执行之前,其中Combiner的输入是Mapper阶段的输出Key-value,输出时Reduce阶段输入的key-value
Combiner代码实现
- 自定义一个combiner继承Reducer,重写reduce方法
public class WordCountCombiner extends Reducer<Text, LongWritable, Text, LongWritable> {
@Override
protected void reduce(Text key, Iterable<LongWritable> values, Context context) throws IOException, InterruptedException {
Iterator<LongWritable> iterator = values.iterator();
long num = 0L;
while (iterator.hasNext()) {
LongWritable next = iterator.next();
long l = next.get();
num += l;
}
context.write(key, new LongWritable(num));
}
}
在job驱动类中设置:
job.setCombinerClass(WordCountCombiner.class);
通过上述代码可以发现,combiner类写的逻辑方法与Reduce阶段的相同(一般情况下两个的处理逻辑都一样),所以可以直接在Driver中设置Combiner类为Reduce类。注意:虽然二者代码一样,但是执行的逻辑不一样。
在job驱动类中设置
job.setCombinerClass(WordCountReduce.class);
Combiner总结
- Combiner执行时机在Mapper结束之后,Reducer开始之前
- Combiner操作可选择,可以添加也可以不添加,不添加的话Map阶段的数据会直接传给Reducer。如果加上这个操作,那么Map阶段的数据先给Combiner,再从Combiner给Reducer。同时注意,map阶段的输出就是Combiner的输入,Combiner的输出就是Reducer的输入
- Combiner和Reducer的逻辑非常像,都是根据key值,将相同的key值的value值集中起来走一次Reducer。不同点在于Combiner对每一个MapTask都会去执行一次,而Reducer是将每一个Maptask的数据全部拉过来再去执行,这样效率比较低
GroupingComparator分组(辅助排序)
分组排序简介
这个分组排序又叫辅助排序,也是可选的。但是在有些业务条件下,分组排序必须存在。
主要作用:如果我们定义的key是JavaBean对象,我们可以在分组中将部分字段相同的bean对象当做同一个key处理。
分组排序的时机
map阶段数据分区排序合并结束,reduce已经将map阶段的输出数据读取到内存中,即将执行Reducer方法之前
分组排序的作用
- 将数据进行排序(一般情况下都用WritableComparable)
- 判断满足什么条件key值就相等了。
- Text类型判断的就是值,就是相等的key;
- LongWritable类型也是判断值相等,就是相等的key;
- 对于自定义的JavaBean对象,如何判断相等呢?是判断地址相等吗?
【补充】
例如:Student s1 = new Student("zs", "8374772648327498");
Student s2 = new Student("ls", "8374772648327498");
这两个对象相等吗?
从代码角度而言,这两个对象不相等,因为他们的地址不相等
但是Java是面向对象编程的语言,面向对象就是拿现实的逻辑去理解代码,所以从现实的角度来看,这两个对象是同一个对象,因为他们的身份证号一样
所以需要主要一个问题,在Java中,比较基本数据类型是否相等,应该用 == 去判断;但是如果需要比较两个引用数据是否相等,需要通过两个对象的属性值是否相等来判断两个对象是否为同一个对象,一般使用equals方法和compare方法
有了以上基础,再看上面的问题。对于JavaBean对象是否相等的判断,不应该用地址判断,我们需要判断对象中的某些属性是否相等。所以在MR程序中,如果你想根据我们自己的逻辑将不同的JavaBean对象当做同一个key处理,我们可以通过分组辅助排序实现。当然我们的WritableComparable也可以去实现,但是在业务逻辑上这个接口主要是做排序使用的。如果想在Reducer阶段让不同的Java对象成为一组相同的key,那么你就需要定义一个分组排序规则,在里面定义一下哪个对象是相同的key
分组排序的实现思路
案例:订单排序
有如下订单数据
|
订单id |
商品id |
成交金额 |
|
0000001 |
Pdt_01 |
222.8 |
|
0000001 |
Pdt_05 |
25.8 |
|
0000002 |
Pdt_03 |
522.8 |
|
0000002 |
Pdt_04 |
122.4 |
|
0000002 |
Pdt_05 |
722.4 |
|
0000003 |
Pdt_01 |
222.8 |
|
0000003 |
Pdt_02 |
33.8 |
现在需要求出每一个订单中最贵的商品。
- 继承extendsWritableComparator
- 定义一个构造器,指定判断相等的是那个JavaBean对象,并且传一个true(如果不定义,会报空指针异常的错误,因为自定义的比较器默认传入的值都是null值)
- 重写compare(WritableComparable a, WritableComparable b) 方法,在里面写我们比较的逻辑
【注意】这里的参数都是WritableComparable类型,实际上是Mapper阶段输出的key的类型。这里可以使用多态的方式对参数进行强转
- 在Driver类中对分组排序类进行配置
job.setGroupingComparatorClass(OrderGroup.class);
public class OrderGroupComparable extends WritableComparator {
public OrderGroupComparable() {
super(Order.class, true);
}
@Override
public int compare(WritableComparable a, WritableComparable b) {
Order order1 = (Order) a;
Order order2 = (Order) b;
if (order1.getId() > order2.getId()) {
return -1;
} else if (order1.getId() == order2.getId()) {
return 0;
} else {
return -1;
}
}
}
运行截图

MapReduce框架原理--Shuffle机制的更多相关文章
- Hadoop(17)-MapReduce框架原理-MapReduce流程,Shuffle机制,Partition分区
MapReduce工作流程 1.准备待处理文件 2.job提交前生成一个处理规划 3.将切片信息job.split,配置信息job.xml和我们自己写的jar包交给yarn 4.yarn根据切片规划计 ...
- MapReduce(五) mapreduce的shuffle机制 与 Yarn
一.shuffle机制 1.概述 (1)MapReduce 中, map 阶段处理的数据如何传递给 reduce 阶段,是 MapReduce 框架中最关键的一个流程,这个流程就叫 Shuffle:( ...
- Hadoop(14)-MapReduce框架原理-切片机制
1.FileInputFormat切片机制 切片机制 比如一个文件夹下有5个小文件,切片时会切5个片,而不是一个片 案例分析 2.FileInputFormat切片大小的参数配置 源码中计算切片大小的 ...
- 【待完成】[MapReduce_9] MapReduce 的 Shuffle 机制
0. 说明 待补充...
- Hadoop_18_MapRduce 内部的shuffle机制
1.Mapreduce的shuffle机制: Mapreduce中,map阶段处理的数据如何传递给Reduce阶段,是mapreduce框架中最关键的一个流程,这个流程就叫shuffle 将mapta ...
- MapReduce实例2(自定义compare、partition)& shuffle机制
MapReduce实例2(自定义compare.partition)& shuffle机制 实例:统计流量 有一份流量数据,结构是:时间戳.手机号.....上行流量.下行流量,需求是统计每个用 ...
- hadoop MapReduce Yarn运行机制
原 Hadoop MapReduce 框架的问题 原hadoop的MapReduce框架图 从上图中可以清楚的看出原 MapReduce 程序的流程及设计思路: 首先用户程序 (JobClient) ...
- java大数据最全课程学习笔记(6)--MapReduce精通(二)--MapReduce框架原理
目前CSDN,博客园,简书同步发表中,更多精彩欢迎访问我的gitee pages 目录 MapReduce精通(二) MapReduce框架原理 MapReduce工作流程 InputFormat数据 ...
- MapReduce框架原理
MapReduce框架原理 3.1 InputFormat数据输入 3.1.1 切片与MapTask并行度决定机制 1.问题引出 MapTask的并行度决定Map阶段的任务处理并发度,进而影响到整个J ...
随机推荐
- 基于 CentOS 8 搭建 openLDAP 服务器
转载请注明原文地址:基于 CentOS 8 搭建 openLDAP 服务器 环境 OS: CentOS 8.4.2105 PHP: 7.4.21 注意 CentOS 7 中可能默认提供了 openLD ...
- Retrofit使用Kotlin协程发送请求
Retrofit2.6开始增加了对Kotlin协程的支持,可以通过suspend函数进行异步调用.本文简单介绍一下Retrofit中协程的使用 导入依赖 app的build文件中加入: impleme ...
- Java:Java的堆区、栈区和方法区详解
Java内存空间理解 堆:堆主要存放Java在运行过程中new出来的对象,凡是通过new生成的对象都存放在堆中,对于堆中的对象生命周期的管理由Java虚拟机的垃圾回收机制GC进行回收和统一管理.类的非 ...
- linux 退出状态码
状态码 描述 0 命令成功结束 1 一般性未知错误 2 不适合的shell 命令 123 命令不可执行 127 没找到命令 128 无效退出参数 128+x 与linux信号x相关的严重错误 130 ...
- awk中printf的用法
printf函数 打印输出时,可能需要指定字段间的空格数,从而把列排整齐.在print函数中使用制表符并不能保证得到想要的输出,因此,可以用printf函数来格式化特别的输出. printf函数返 ...
- webpack(10)webpack-dev-server搭建本地服务器
前言 当我们使用webpack打包时,发现每次更新了一点代码,都需要重新打包,这样很麻烦,我们希望本地能搭建一个服务器,然后写入新的代码能够自动检测出来,这时候就需要用到webpack-dev-ser ...
- Linux必备命令 - 常用命令集
默认进入系统,我们会看到这样的字符: [root@localhost ~]#,其中#代表当前是root用户登录,如果是$表示当前为普通用户.cd 命令 cd /home :解析:进入/home目录 ...
- NB-IoT物联网连接
一.NB-1oT的专有能力物联网(Internet of Things).简称IoTNB-IoT就是指窄带物联网(Narrow Band-Internet of Things)技术目前关于NB-IoT ...
- VUE+ElementUI实现左侧为树形结构、右侧无层级结构的穿梭框
工作中遇到一个需求,需要将一个数据选择做成穿梭框,但是要求穿梭框左侧为树形结构.右侧为无层级结构的数据展示,ElementUI自身无法在穿梭框中添加树形结构,网上搜到了大佬封装的插件但是对于右侧的无树 ...
- 剖析:如何用 SwitchUI 5天写一个微信 —— 聊天界面篇
前置资源 GitHub: SwiftUI-WeChatDemo 第零章:用 SwiftUI 五天组装一个微信 - wavky - 博客园 整体结构 UI 部分代码分布如上图所示,App 的主入口类为 ...