news 2026/10/17 2:55:08

SelectMany本质:一对多关系的扁平化展开而非Select加强版

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SelectMany本质:一对多关系的扁平化展开而非Select加强版

1. 为什么SelectMany不是“Select的加强版”,而是彻底不同的思维范式

刚接触LINQ的开发者,十有八九会把SelectMany当成Select的“升级版”——毕竟名字里带个“Many”,直觉上就是“选得更多”。我第一次在某跨平台系统中调试数据聚合逻辑时,也这么想。结果写了一堆嵌套foreach去展开集合,最后发现SelectMany一行就能搞定,而且性能还更好。这不是语法糖的问题,是根本性认知偏差。

Select的本质是一对一映射:输入一个元素,输出一个元素(哪怕这个元素是个集合)。而SelectMany是一对多展开再扁平化:输入一个元素,它关联着多个子元素,SelectMany的任务是把这些子元素全部“摊开”到同一层级,抹掉原始的嵌套结构。这就像整理一摞装着文件夹的文件夹——Select只是给每个外层文件夹贴个新标签;SelectMany则是把所有内层文件夹里的纸张全抽出来,混在一起重新排序。

关键词里没填内容,但标题中“Chinar”这个署名很关键。它暗示这不是泛泛而谈的API文档复述,而是某个实战者(我们暂称Chinar)在真实项目中反复踩坑、验证后沉淀下来的可视化理解方式。所以本文不讲定义,直接从三个最常被误解的场景切入:为什么用SelectMany而不是双重Select?为什么它比手写循环更安全?以及那个让无数人调试到凌晨三点的“空集合陷阱”。

提示:SelectMany的签名里藏着第一个重要线索——它的第二个参数不是简单的Func<T, TResult>,而是Func<T, IEnumerable<TResult>>。这意味着它要求你明确告诉它:“这个父元素,能生成哪些子元素”,而不是“这个父元素,应该变成什么”。思维重心从“转换”转移到了“展开关系”。

我们先看一个具体例子。假设你正在开发一个模拟项目X,需要处理用户订单数据。每个User对象包含一个Orders集合,每个Order又包含多个Items。现在需求是:列出所有用户购买过的商品名称,不区分用户、不区分订单,只要商品名列表。

用Select怎么做?

var allItems = users.Select(u => u.Orders.Select(o => o.Items));

这段代码返回的是IEnumerable<IEnumerable<IEnumerable<Item>>>——三层嵌套!你得到的是一堆“商品集合的集合的集合”,根本没法直接遍历商品名。这时候如果强行加.ToList(),你会发现内存里存的全是引用,实际数据还在深层结构里锁着。

而SelectMany的写法是:

var allItems = users.SelectMany(u => u.Orders) .SelectMany(o => o.Items) .Select(i => i.Name);

或者更简洁地链式展开:

var allItems = users.SelectMany(u => u.Orders, (u, o) => o) .SelectMany(o => o.Items, (o, i) => i) .Select(i => i.Name);

注意第二行的(u, o) => o——这是SelectMany的重载签名中关键的“结果选择器”。它不是必须的,但一旦使用,就说明你在展开过程中要同时保留父级上下文。比如你想生成“用户姓名-商品名”的组合,就可以写成(u, o) => new { u.Name, ItemName = o.ItemName }。这种能力是双重Select永远做不到的,因为它没有提供访问“父-子”双变量的入口。

这就是第一个核心认知:SelectMany解决的不是“怎么变”,而是“怎么解构关系”。它天然适配一对多、多对多的数据模型,而Select只适合树状结构的单层投影。在某高校的数据库课程设计中,学生常把SelectMany用在EF Core查询中连接Authors和Books表,结果发现生成的SQL是INNER JOIN而非LEFT JOIN——这恰恰证明了它的关系展开本质:它默认只处理“存在关联”的记录,空关系会被自动过滤。

2. 图解SelectMany的三步执行引擎:从委托调用到迭代器状态机

光说概念容易飘,我们拆开SelectMany的内部齿轮看看它怎么转。很多人以为它只是语法糖,编译后等价于嵌套循环。其实不然。C#编译器对SelectMany做了特殊优化,它生成的迭代器状态机比手写循环更精巧,也更容易预测行为。

我们以最简形式分析:source.SelectMany(selector)。它的执行分三步,每一步都对应一个可观察的中间状态:

2.1 第一步:获取每个源元素的子序列(延迟执行)

SelectMany不会立刻遍历整个source。它先创建一个迭代器对象,这个对象持有着source的枚举器和selector委托。只有当你第一次调用MoveNext()时,它才从source中取第一个元素,然后立即用selector去调用这个元素,得到一个IEnumerable<TResult>。注意,这里得到的不是数据,而是一个“待展开的承诺”——比如user.Orders这个属性,它可能是一个List<Order>,也可能是一个延迟加载的EF Core导航属性,甚至是一个异步返回的IAsyncEnumerable<Order>(在.NET 6+中)。

这个设计带来了第一个实操优势:内存友好。假设你有10万个用户,但只需要前100个用户的订单商品,SelectMany只会为前几个用户调用selector,不会像ToList().Select(...).SelectMany(...)那样先把全部数据加载进内存。我在某图像处理Demo中处理百万级像素块时,就靠这个特性避免了OOM——每次只展开当前扫描行关联的邻域像素集合。

2.2 第二步:扁平化展开(逐个子序列迭代)

拿到第一个子序列(比如user1.Orders)后,SelectMany不是把它整个缓存起来,而是立刻创建这个子序列的枚举器,并开始MoveNext()。它把子序列的第一个元素作为最终结果返回,然后暂停,等待下一次MoveNext()。当子序列遍历完,它才回去取source的第二个元素,重复第一步。

这个机制决定了SelectMany的流式处理特性。你可以把它想象成一条传送带:上游不断送来“原料块”(源元素),每个原料块进入一个“压模机”(selector),压出一串“薄片”(子元素),这些薄片直接落到主传送带上,无缝衔接。没有中间仓库,没有批量搬运。

我们用一个生活类比:快递分拣站。source是 incoming 包裹流,每个包裹(源元素)上有收件人信息。selector是分拣规则——比如“提取该包裹的所有子件编号”。SelectMany不是先把所有包裹拆开、把子件堆成山再分拣,而是每个包裹到站,立刻拆开,把子件一个个贴上新单号,直接放上传送带发往下一站。这样吞吐量高,延迟低,且不会因为某个包裹里有1000个子件就把整个流水线堵死。

2.3 第三步:结果选择器的上下文注入(重载精髓)

现在看带两个参数的重载:SelectMany(source, collectionSelector, resultSelector)。这才是SelectMany真正展现威力的地方。它的执行流程多了关键一环:

  1. 从source取一个元素x;
  2. 用collectionSelector(x)得到子序列yCollection;
  3. 遍历yCollection,对每个y,调用resultSelector(x, y)生成最终结果。

注意第三步:x(父元素)在整个子序列遍历过程中始终可用。这意味着你可以在结果中同时携带父级标识和子级数据。比如在日志分析系统中,你需要把“服务名-接口名-响应时间”三元组打平,就可以:

logs.SelectMany( log => log.ApiCalls, (log, call) => new { Service = log.ServiceName, Api = call.Endpoint, Time = call.Duration } );

这个重载无法用Select+Select替代,因为Select的委托只能访问当前元素,无法回溯父级。而SelectMany的resultSelector参数,本质上是在构建一个“笛卡尔积”的简化版——但它只在有实际关联时才生成配对,不像Join那样需要显式匹配条件。

注意:resultSelector的调用时机非常关键。它在每次子元素迭代时触发,所以如果你在里面做耗时操作(如IO、复杂计算),性能会随子元素数量线性下降。我在某公司做实时监控告警模块时,曾把数据库查询放在resultSelector里,结果单个日志条目关联100个指标,就触发100次DB查询——后来重构为先SelectMany展开ID,再批量查库,QPS提升了8倍。

3. 真实项目中的四大高频误用场景与修复方案

理论再扎实,不如看血泪教训。我在参与多个模拟项目X的过程中,见过太多因误解SelectMany导致的线上问题。下面四个场景,每个都附带可复现的错误代码、调试过程和终极修复。

3.1 场景一:空集合引发的“静默消失”——你以为的数据丢了,其实是被过滤了

错误代码:

var users = new List<User> { new User { Name = "Alice", Orders = new List<Order>() }, new User { Name = "Bob", Orders = new List<Order> { new Order { Id = 1 } } } }; var orderIds = users.SelectMany(u => u.Orders).Select(o => o.Id).ToList(); // 期望:[1],实际:[1] —— 看似正确?

问题藏在更复杂的业务逻辑里。假设你后续要统计“有订单的用户数”,你可能会写:

var activeUserCount = users.SelectMany(u => u.Orders).Count() > 0 ? users.Count : 0;

这行代码永远返回0,因为SelectMany遇到空集合时,不会生成任何元素,Count()结果是0,条件为假。

调试过程:
我在某跨平台系统的用户活跃度报表中遇到此问题。前端显示“今日0活跃用户”,但数据库明明有数据。用Visual Studio调试器逐帧查看,发现SelectMany后的序列长度为0,但users本身有1000条。于是检查每个user.Orders,发现70%的用户订单集合是空List,而非null。SelectMany对空集合的处理是“跳过”,不是“返回空”,所以整个序列被清空。

修复方案:
明确区分“空集合”和“无数据”语义。如果业务上需要保留空关系的占位,改用Select+DefaultIfEmpty:

var orderIdsWithNulls = users.Select(u => u.Orders.DefaultIfEmpty()) .SelectMany(orders => orders) .Select(o => o?.Id); // o可能是null

或者更推荐的方式:在业务层预处理,用Where过滤掉无效用户:

var validUsers = users.Where(u => u.Orders != null && u.Orders.Any()); var orderIds = validUsers.SelectMany(u => u.Orders).Select(o => o.Id).ToList();

3.2 场景二:延迟执行陷阱——你以为的“已计算”,其实是“待触发”

错误代码:

var query = users.SelectMany(u => u.Orders); Console.WriteLine("Query built"); // 后续其他代码... Thread.Sleep(1000); Console.WriteLine($"Count: {query.Count()}"); // 这里才真正执行!

问题本质:
SelectMany返回的是IQueryable<T>或IEnumerable<T>,它们是延迟执行的。query变量只是一个“查询计划”,不是数据。Count()、ToList()、foreach这些操作才是真正的执行点。如果users是EF Core的DbSet<User>,那么Count()会触发SQL查询;如果是内存集合,则触发迭代。

我在某实验室的实验数据处理脚本中栽过跟头。脚本开头构建了十几个SelectMany查询,存入字典,然后在循环中逐个调用Count()。结果发现,每次Count()都重新遍历整个源集合,CPU飙升。因为SelectMany的迭代器没有缓存,每次调用都从头开始。

修复方案:
根据场景选择执行策略:

  • 如果数据量小且需多次访问,立即执行:var list = query.ToList();
  • 如果数据量大且只需一次遍历,保持延迟:foreach (var item in query) { ... }
  • 如果需统计+遍历,用Count()+ToList()组合,但注意内存:
    var materialized = query.ToList(); Console.WriteLine($"Count: {materialized.Count}"); foreach (var item in materialized) { ... }

3.3 场景三:导航属性未加载——EF Core中“空集合”的真正元凶

错误代码(EF Core):

var users = context.Users.ToList(); // 只查了Users表 var allOrders = users.SelectMany(u => u.Orders).ToList(); // Orders为空!

根因分析:
EF Core默认不加载导航属性(Lazy Loading关闭时)。users列表中的每个User对象,其Orders属性是null,不是空集合。SelectMany对null调用会抛出ArgumentNullException。但很多开发者没看到异常,因为上层try-catch吞掉了,或者日志级别太低。

我在某公司重构旧系统时,发现一个报表接口响应时间从200ms暴涨到15秒。用MiniProfiler追踪,发现SelectMany触发了N+1查询——因为u.Orders是null,EF Core在SelectMany内部尝试访问时,触发了懒加载代理,为每个用户单独查一次Orders表。

修复方案:
强制包含导航属性:

var users = context.Users.Include(u => u.Orders) .ThenInclude(o => o.Items) .ToList();

或者用Select投影避免实体加载:

var orderIds = context.Users .SelectMany(u => u.Orders, (u, o) => o.Id) .ToList();

后者生成单条SQL,效率最高。

3.4 场景四:异步流中的类型错配——IAsyncEnumerable不能直接SelectMany

错误代码(.NET 6+):

async IAsyncEnumerable<Order> GetOrdersAsync(User user) { ... } var allOrders = users.SelectMany(u => GetOrdersAsync(u)); // 编译错误!

原因:
SelectMany的泛型约束要求TSource是IEnumerable<T>,而IAsyncEnumerable<T>不满足。.NET 6引入了SelectMany的异步重载,但需要显式调用:

var allOrders = users.SelectManyAsync(u => GetOrdersAsync(u));

但这需要引用System.Linq.Async包,且SelectManyAsync返回IAsyncEnumerable<T>,不能直接await,必须用await foreach。

修复方案:

  • 方案A(推荐):用Select+await+ToListAsync分步处理:
    var orderLists = await users.Select(u => GetOrdersAsync(u)) .Select(async x => await x.ToListAsync()) .WhenAll(); // 自定义扩展方法 var allOrders = orderLists.SelectMany(x => x).ToList();
  • 方案B:接受流式处理,用await foreach:
    await foreach (var order in users.SelectManyAsync(u => GetOrdersAsync(u))) { Process(order); }

4. 性能对比实测:SelectMany vs 手写循环 vs ForEach嵌套

光说不练假把式。我们在模拟项目X中设计了一个标准测试:10万个用户,每个用户平均3个订单,每个订单5个商品。测量三种方式生成所有商品名列表的耗时和内存占用(.NET 6, Release模式, 无调试器)。

方式代码片段平均耗时内存峰值GC次数关键特点
SelectMany链式users.SelectMany(u=>u.Orders).SelectMany(o=>o.Items).Select(i=>i.Name).ToList()82ms42MB1延迟执行,LINQ优化,代码最简
手写双重foreachvar list=new List<string>(); foreach(var u in users) foreach(var o in u.Orders) foreach(var i in o.Items) list.Add(i.Name);65ms38MB0最快,内存最省,但代码冗长易错
Select嵌套users.Select(u=>u.Orders.Select(o=>o.Items.Select(i=>i.Name))).SelectMany(x=>x).SelectMany(x=>x).ToList()145ms68MB2创建大量中间集合,GC压力大

深度解读:

  • SelectMany比手写循环慢17ms,但换来的是可维护性。当业务逻辑变化(比如要加过滤条件),SelectMany只需加.Where(),而手写循环要改三层嵌套。
  • Select嵌套的灾难在于中间对象爆炸。第一层Select生成10万个IEnumerable<IEnumerable<string>>对象,第二层展开时又创建10万3个IEnumerable<string>,第三层再创建10万3*5个string引用——这些对象很快进入Gen1,触发频繁GC。
  • SelectMany的迭代器状态机是栈式分配,大部分状态存在栈上,只有必要时才分配堆对象(如闭包),所以内存更干净。

实操心得:
在某图像处理Demo中,我需要对每个像素块的8邻域做卷积。最初用SelectMany展开邻域坐标,但发现性能瓶颈在SelectMany的委托调用开销(每个像素调用2次委托)。换成Span<T>+指针算术后,速度提升3倍。这说明:SelectMany是高级抽象,不是银弹。当性能是唯一目标,且数据结构固定时,底层优化仍不可替代。但90%的业务代码,SelectMany的可读性和稳定性远胜微秒级的性能差异。

我们再看一个反直觉结论:SelectMany在大数据集上反而更稳。因为它的延迟执行特性,可以配合Take(100)实现“只取前N个结果”,而手写循环必须遍历全部数据才能知道前100个在哪。在实时搜索建议场景中,用户输入“a”,后端用SelectMany展开所有以a开头的商品名,加.Take(10),毫秒级返回,内存占用恒定。

5. 高阶技巧:用SelectMany实现分页、去重与条件展开

SelectMany的能力远不止“展开集合”。结合其他LINQ操作符,它能解决更复杂的业务问题。以下是我在多个项目中沉淀的三个高阶模式。

5.1 模式一:分页展开——避免“先展开再分页”的内存灾难

问题:
用户列表有100万条,每个用户平均100个订单。要获取“所有订单中,按时间倒序的前100条”。如果先SelectMany再OrderByDescending+Take(100),内存会爆——因为要先展开1亿个订单。

解决方案:
用SelectMany的索引重载,分页拉取:

var pageSize = 100; var page = 0; var pagedOrders = users.Skip(page * pageSize) .Take(pageSize) .SelectMany(u => u.Orders, (u, o) => new { u.Id, o.Id, o.CreatedAt }) .OrderByDescending(x => x.CreatedAt) .Take(100);

但这只取了前100个用户的订单。更优解是数据库端分页:

// EF Core中,用SelectMany生成JOIN查询 var topOrders = context.Users .SelectMany(u => u.Orders, (u, o) => new { u.Name, o.Id, o.CreatedAt }) .OrderByDescending(x => x.CreatedAt) .Take(100) .ToList();

生成的SQL是SELECT TOP(100) ... FROM Users u INNER JOIN Orders o ON u.Id = o.UserId ORDER BY o.CreatedAt DESC,完全在数据库执行。

5.2 模式二:条件展开——动态决定是否展开某个子集合

需求:
只展开VIP用户的订单,普通用户跳过。不能用Where过滤用户,因为要保留用户信息用于后续处理。

解决方案:
用Select返回IEnumerable,空集合表示不展开:

var conditionalOrders = users.Select(u => u.IsVip ? u.Orders : Enumerable.Empty<Order>()) .SelectMany(x => x) .ToList();

或者更函数式:

var conditionalOrders = users.SelectMany(u => u.IsVip ? u.Orders : Enumerable.Empty<Order>());

5.3 模式三:去重展开——避免“用户A买了iPhone,用户B也买了iPhone”重复出现

问题:
SelectMany展开后,商品名会大量重复。Distinct()可以去重,但会丢失上下文(比如哪个用户买的)。

解决方案:
用GroupBy+SelectMany实现“按商品分组,取第一个购买者”:

var firstBuyers = users.SelectMany(u => u.Orders, (u, o) => new { u.Name, o.ItemName }) .GroupBy(x => x.ItemName) .Select(g => new { Item = g.Key, FirstBuyer = g.First().Name });

这行代码的执行计划是:先展开所有(用户,商品)对,再按商品名分组,每组取第一个元素。生成的SQL在EF Core中是高效的GROUP BY。

提示:SelectMany和GroupBy是绝配。GroupBy需要一个“可分组的扁平序列”,而SelectMany正是生成这种序列的最佳工具。在某高校的学生成绩分析系统中,我们用SelectMany展开“学生-课程-成绩”,再GroupBy课程,计算每门课的平均分、及格率,代码不到10行,可读性极强。

6. 从Chinar到你的项目:如何建立SelectMany的肌肉记忆

看到这里,你可能觉得SelectMany很强大,但怎么才能像呼吸一样自然地用它?我的经验是:不要背语法,而是建立三个条件反射。

6.1 反射一:看到“一对多”,立刻想到SelectMany

在代码审查中,只要见到以下模式,就该警觉:

  • foreach (var parent in parents) { foreach (var child in parent.Children) { ... } }
  • parents.Select(p => p.Children).SelectMany(x => x)
  • from p in parents from c in p.Children select ...(查询语法)

这些都是SelectMany的信号灯。立即重构:

// 重构前 var result = new List<Child>(); foreach (var p in parents) { foreach (var c in p.Children) { if (c.IsActive) result.Add(c); } } // 重构后 var result = parents.SelectMany(p => p.Children.Where(c => c.IsActive)).ToList();

6.2 反射二:需要“父+子”上下文时,锁定重载版本

当你写的逻辑中,子元素的处理依赖父元素的某个字段(比如“订单金额大于用户信用额度”),就必须用双参数重载:

// 错误:无法访问user.CreditLimit orders.Select(o => o.Amount > ???); // 正确:用resultSelector捕获上下文 var validOrders = users.SelectMany( u => u.Orders, (u, o) => new { User = u, Order = o } ).Where(x => x.Order.Amount <= x.User.CreditLimit) .Select(x => x.Order);

6.3 反射三:调试时,永远先检查“空”和“null”

SelectMany的两大敌人:空集合(被跳过)和null引用(抛异常)。在VS调试器中,养成习惯:

  • 鼠标悬停source,看Count;
  • 展开第一个元素,看其子集合是否为null或空;
  • 在SelectMany后加.ToList()强制执行,快速验证数据流。

我在某公司带新人时,让他们写一个“找出所有用户购买过的品牌列表”的功能。80%的人第一版代码都漏了空订单检查,导致测试数据中3个用户没订单,结果品牌列表少了1/3。后来我们加了单元测试断言:

Assert.That(result, Has.Count.EqualTo(users.Sum(u => u.Orders?.Count() ?? 0)));

用数学公式校验展开总数,成了团队标配。

最后分享一个小技巧:在Rider或VS中,把SelectMany的XML注释打开,重点读这一句:“Projects each element of a sequence to an IEnumerable and flattens the resulting sequences into one sequence.” 记住“projects...and flattens”,不是“selects...and joins”。投影(project)意味着转换关系,扁平化(flatten)意味着消除层级。抓住这两个词,你就抓住了SelectMany的灵魂。

我在实际使用中发现,真正掌握SelectMany的标志,不是能写出复杂链式调用,而是能在看到嵌套循环时,本能地问一句:“这个结构,能不能用SelectMany一句话说清楚?” 当你的思维从“怎么写循环”切换到“怎么描述关系”,你就已经超越了大多数开发者。

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

单人版森林冰火人Java源码解析:Swing、碰撞检测与双角色控制

简介&#xff1a;一份基于Java开发的趣味小游戏源码压缩包&#xff0c;定位为适合Java入门者练手、学习游戏编程的完整示例项目。资源共78个文件&#xff0c;包含6个java源文件与7个class编译文件、jpg图片素材48个、gif动图14个&#xff0c;以及其他工程配置类文件&#xff0c…

作者头像 李华
网站建设 2026/10/17 2:54:26

Vue 3 + Cordova 12 实战:调用定位、振动、相册、扫码四大原生能力

简介&#xff1a;本资源是一份面向Vue前端开发者与混合移动应用初学者的Cordova原生能力集成实战指南&#xff0c;聚焦解决Web项目在真机环境中调用地理位置、相机、振动、二维码扫描等系统级功能的核心痛点。资源以完整可运行的VueCordova项目为基础&#xff0c;涵盖cordova-p…

作者头像 李华
网站建设 2026/10/17 2:54:17

Linux一键安装JDK、MySQL、Redis、Nginx:脚本拆解与避坑指南

简介&#xff1a;这是一套面向Linux运维初学者与后端开发者的自动化部署资源包&#xff0c;围绕JDK、MySQL、Redis、Nginx四大核心组件的安装与配置展开&#xff0c;帮助读者在Linux服务器上快速搭建高性能Web服务环境&#xff0c;省去逐个手动编译与调试的繁琐过程。压缩包共2…

作者头像 李华
网站建设 2026/10/17 2:54:12

JSP会议室预约系统实战:冲突检测与并发优化

简介&#xff1a;这是一套面向Java Web初学者与课程设计开发者的会议室预约系统完整源码&#xff0c;基于JSP、Servlet与JavaBean技术构建&#xff0c;用于解决企业或组织内部会议室资源分配与冲突问题。系统区分管理员与员工两类角色&#xff1a;管理员负责部门、员工账户、会…

作者头像 李华
网站建设 2026/10/17 2:54:07

编译原理实验代码:从词法分析到目标代码生成的完整实现

简介&#xff1a;这份资源是电子科技大学编译原理课程的实验代码合集&#xff0c;面向正在学习编译原理、需要完成词法分析与语法分析实验的高校学生及自学者。包内共21个文件&#xff0c;以cpp与h源码为主&#xff0c;辅以pas示例、vcxproj与sln工程文件、docx运行说明及exe可…

作者头像 李华
网站建设 2026/10/17 2:52:46

Ionic+Cordova插件集成Unity:让AR功能成为原生模块

简介&#xff1a;UnityIonicIntegration 是一份针对 Unity 3D 内容与 Ionic 混合应用双向集成的实战指南&#xff0c;适合需要同时掌握 Android/iOS 端插件封装与原生通信的移动开发者。方案基于 Cordova 插件实现 Unity 与 Ionic 之间双向消息传递&#xff0c;并已在带 Vufori…

作者头像 李华