从建筑设计院转行C#上位机:我的第一次面试,尴尬到想钻地缝
我是豪彡,从建筑设计院跑路,自学C#转行上位机开发。这篇文章记录我第一次面试的真实经历——被问到Prism、MVVM、EF Core、串口通信时的尴尬场面,以及我从中学到的具体技术细节。文中附有大量代码示例,希望能帮到同样在转行面试路上的你。
一、投简历之前,我以为自己准备好了
自学了大概八个月,我觉得自己差不多了。
B站上跟着视频敲过几个小项目:串口助手、温度监控、简易PLC数据采集。虽然都是跟着教程走的,但好歹每个项目我都能跑起来,代码也能看懂个七七八八。
我心想:上位机开发嘛,不就是拖拖控件、写写事件、读读串口,能有多难?
于是我开始投简历。
投了大概三十家,收到了三个面试邀请。第一个是一家做工业自动化的小公司,岗位是“.NET上位机开发工程师”。HR在电话里说:“我们这边用的是WPF + Prism框架,你熟悉吗?”
我愣了一下。WPF我听过,Prism是什么?但我嘴上还是说:“了解一些,用过WPF。”
HR说:“那行,明天下午两点,你过来聊聊。”
挂了电话,我赶紧打开B站搜“Prism框架入门”。看了两个小时,大概知道它是个用来做模块化开发的框架,但具体怎么用,完全没概念。
我想:面试嘛,大不了就说没接触过,反正我WPF基础还是有的。
后来我才知道,这个想法有多天真。
二、面试现场:从“还行”到“完全懵了”
面试我的是技术负责人,姓周,看起来四十出头,穿一件格子衬衫,桌上摆着两个显示器,一个竖着一个横着。后来我才知道,竖着的那个是用来看代码的。
周工很客气,先让我自我介绍。我把自己转行的经历说了一遍:建筑设计院画图、加班改方案、自学C#、做过几个小项目。他听完点点头,说:“转行不容易,能自学到这一步挺有毅力的。”
我心里一暖,觉得有戏。
然后他开始问技术问题。
第一轮:WPF基础
问题1:“你用过WPF的数据绑定吗?说说Binding的几种模式。”
这个我会,答了OneWay、TwoWay、OneTime、OneWayToSource、Default。
周工点点头,追问:“UpdateSourceTrigger有哪些值?默认值是什么?”
我说:“PropertyChanged、LostFocus、Explicit、Default。TextBox.Text的默认值是LostFocus,其他大部分是PropertyChanged。”
周工说:“还行。那你知道INotifyPropertyChanged怎么实现吗?”
我说:“实现接口,在属性的setter里调用PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name))。”
周工说:“那你手写一个给我看看。”
我拿起笔,在纸上写:
public class DeviceViewModel : INotifyPropertyChanged
{
private string _deviceName;
public string DeviceName
{
get => _deviceName;
set
{
if (_deviceName != value)
{
_deviceName = value;
PropertyChanged?.Invoke(this,
new PropertyChangedEventArgs(nameof(DeviceName)));
}
}
}
public event PropertyChangedEventHandler PropertyChanged;
}
周工看了一眼,说:“对,但实际项目里你不会每个ViewModel都手写这一套。你知道BindableBase吗?”
我说:“Prism里的那个基类?”
周工说:“对,Prism提供了BindableBase,你继承它之后,只需要写SetProperty(ref _deviceName, value)就行。你用过吗?”
我老实说:“没用过。”
第二轮:Prism与MVVM
问题2:“你了解Prism框架吗?”
我说:“了解一些,知道它是用来做模块化开发的,有Module、Region、EventAggregator这些概念。”
周工眼睛亮了一下:“那你用过EventAggregator吗?”
我说:“看过文档,但项目里没实际用过。”
周工说:“没关系,那我问你一个场景题。假设你有两个模块,模块A需要通知模块B更新数据,但模块A不能直接引用模块B,你怎么做?”
我想了想,说:“可以用事件。”
周工说:“对,用Prism的EventAggregator。那你知道Subscribe的时候有一个keepSubscriberReferenceAlive参数吗?它是干什么的?”
我彻底懵了。我看文档的时候只看了个大概,根本没注意到这个参数。
我老实说:“不知道。”
周工说:“那我给你讲一下。Prism的EventAggregator默认用弱引用持有订阅者,keepSubscriberReferenceAlive设为false时,如果订阅者没有其他强引用,它会被垃圾回收,订阅就失效了。所以如果你希望订阅一直有效,要么设为true,要么自己保留SubscriptionToken。”
他接着说:“我再问你,如果你在ViewModel里订阅了事件,页面关闭了,你需要做什么?”
我说:“取消订阅?”
周工说:“对,怎么取消?”
我说:“调Unsubscribe?”
周工说:“对,但你需要保留SubscriptionToken才能取消。所以正确的写法是这样的:”
public class DeviceViewModel : BindableBase, INavigationAware
{
private readonly IEventAggregator _eventAggregator;
private readonly SubscriptionToken _token;
public DeviceViewModel(IEventAggregator eventAggregator)
{
_eventAggregator = eventAggregator;
// 保留Token,方便后续取消订阅
_token = _eventAggregator
.GetEvent<DeviceStatusChangedEvent>()
.Subscribe(OnDeviceStatusChanged,
keepSubscriberReferenceAlive: true);
}
private void OnDeviceStatusChanged(DeviceStatus status)
{
// 更新界面
DeviceStatus = status;
}
// 页面离开时取消订阅,防止内存泄漏
public void OnNavigatedFrom(NavigationContext navigationContext)
{
_eventAggregator
.GetEvent<DeviceStatusChangedEvent>()
.Unsubscribe(_token);
}
public void OnNavigatedTo(NavigationContext navigationContext) { }
public bool IsNavigationTarget(NavigationContext navigationContext) => true;
}
周工说:“如果你不取消订阅,每次打开这个页面都会创建新的ViewModel,旧的ViewModel被事件聚合器引用着,垃圾回收不了,时间长了内存就爆了。”
我听完,后背已经开始冒汗了。
三、最尴尬的部分:EF Core的连环追问
如果说Prism的问题让我懵了,那EF Core的问题就是让我想钻地缝。
周工问:“你项目里数据怎么存的?”
我说:“用的SQLite,直接写的SQL。”
周工说:“没用过EF Core?”
我说:“看过一些教程,但项目里没用过。”
周工说:“那我问你几个EF Core的场景题,你不用紧张,就当聊聊天。”
第一问:Include与笛卡尔积
“假设有一个设备表和一个报警表,一个设备对应多条报警记录。你要查所有设备以及它们的报警记录,怎么写?”
我说:“用Include。”
周工说:“对,Include(d => d.Alarms)。那如果每个设备有100条报警记录,一共100个设备,这样查出来是多少条数据?”
我想了想,说:“10000条?”
周工说:“对,这就是笛卡尔积。EF Core会生成一条JOIN查询,把设备表和报警表的数据全部展开。如果设备表有20个字段,报警表有15个字段,那就是10000行 × 35列的数据量。你知道怎么优化吗?”
我摇了摇头。
周工说:“可以用AsSplitQuery(),EF Core会把它拆成两条查询,先查设备,再查报警,然后在内存里组装。数据量大的时候,性能会好很多。”
// ❌ 单查询:笛卡尔积,数据量爆炸
var devices = await _context.Devices
.Include(d => d.Alarms)
.AsNoTracking()
.ToListAsync();
// ✅ 拆分查询:两条SQL,内存组装
var devices = await _context.Devices
.Include(d => d.Alarms)
.AsSplitQuery()
.AsNoTracking()
.ToListAsync();
周工补充说:“AsSplitQuery也不是万能的。如果数据量不大,单查询反而更快,因为只有一次数据库往返。数据量大的时候,拆分查询才能体现优势。”
第二问:AsNoTracking
“你用EF Core查询只读数据的时候,用不用AsNoTracking()?”
我说:“没用过。”
周工说:“那你查出来的数据,EF Core会一直跟踪它们的状态变化。如果你只是展示数据,不修改,这些跟踪完全是浪费性能。记住,只读查询一定要加AsNoTracking()。”
// ❌ 跟踪查询:EF Core会在ChangeTracker里保存每个实体的快照
var devices = await _context.Devices.ToListAsync();
// ✅ 无跟踪查询:不保存快照,性能更好
var devices = await _context.Devices
.AsNoTracking()
.ToListAsync();
周工说:“你想想,如果你查1000条设备数据,EF Core要为每条数据保存一份原始快照,内存占用是双倍的。如果你只是展示,不修改,这个开销完全没必要。”
第三问:DbContext的生命周期
“如果我要你在一个WPF应用里用EF Core,DbContext你怎么注册?”
我想了想,说:“注册成单例?”
周工笑了:“你注册成单例,多个线程同时用同一个DbContext,会出事的。DbContext不是线程安全的。”
我脸红了。
周工说:“在WPF这种桌面应用里,DbContext最好注册成Transient,或者用IDbContextFactory来创建。每个操作创建一个新的DbContext,用完就释放。这样既安全,又不会出现跨线程的问题。”
// ❌ 错误:单例注册,多线程共用同一个DbContext
services.AddSingleton<ApplicationDbContext>();
// ✅ 正确:Transient注册,每次注入都是新实例
services.AddDbContext<ApplicationDbContext>(
options => options.UseSqlServer(connectionString),
ServiceLifetime.Transient);
// ✅ 或者用工厂模式,手动控制生命周期
services.AddDbContextFactory<ApplicationDbContext>(
options => options.UseSqlServer(connectionString));
// 在Service中使用工厂创建DbContext
public class DeviceService
{
private readonly IDbContextFactory<ApplicationDbContext> _contextFactory;
public DeviceService(IDbContextFactory<ApplicationDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
public async Task<List<Device>> GetDevicesAsync()
{
// 每次操作创建新的DbContext,用完即弃
await using var context = await _contextFactory
.CreateDbContextAsync();
return await context.Devices
.AsNoTracking()
.ToListAsync();
}
}
第四问:N+1查询
“你了解N+1查询问题吗?”
我摇了摇头。
周工说:“比如你查了100个设备,然后循环里访问每个设备的报警记录。如果你没有用Include预加载,每次访问导航属性,EF Core都会单独发一条SQL去查数据库。100个设备就是1+100=101次数据库查询。这就是N+1问题。”
// ❌ N+1查询:1次查设备 + 100次查报警
var devices = await _context.Devices.ToListAsync();
foreach (var device in devices)
{
// 每次循环都触发一次数据库查询!
var alarms = device.Alarms.ToList();
}
// ✅ Include预加载:1次查询搞定
var devices = await _context.Devices
.Include(d => d.Alarms)
.AsNoTracking()
.ToListAsync();
周工说:“N+1问题在Web开发里很常见,但上位机里也有,尤其是你加载设备列表、报警记录、历史数据的时候。记住,能用Include预加载的,就别在循环里访问导航属性。”
第五问:批量操作与事务
周工又问:“如果我要你一次性插入1000条报警记录,你怎么做?”
我说:“循环里一条一条Add,然后SaveChanges?”
周工说:“那样会发1000条INSERT语句,性能很差。EF Core有AddRange,可以批量添加,但本质上还是逐条生成SQL。如果你真的需要高性能批量插入,可以用ExecuteSqlRaw直接写SQL,或者用EFCore.BulkExtensions这样的第三方库。”
// ❌ 逐条添加:1000条INSERT
foreach (var alarm in alarms)
{
_context.Alarms.Add(alarm);
}
await _context.SaveChangesAsync();
// ✅ AddRange:EF Core会优化,但仍是逐条INSERT
_context.Alarms.AddRange(alarms);
await _context.SaveChangesAsync();
// ✅ 原生SQL批量插入:性能最好
await _context.Database.ExecuteSqlRawAsync(
"INSERT INTO Alarms (DeviceId, Message, CreatedAt) VALUES (@p0, @p1, @p2)",
alarms.Select(a => new object[] { a.DeviceId, a.Message, a.CreatedAt })
);
周工补充说:“还有一点,如果你要批量操作,记得用事务包起来,保证要么全部成功,要么全部失败。”
await using var transaction = await _context.Database
.BeginTransactionAsync();
try
{
_context.Alarms.AddRange(alarms);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
四、串口通信的追问:粘包与拆包
周工问:“你做过串口通信,那你知道怎么处理粘包和拆包吗?”
我有点慌。粘包?拆包?我做的串口助手就是直接SerialPort.DataReceived里读数据,从来没见过什么粘包。
我老实说:“我用的场景比较简单,数据量不大,没遇到过这个问题。”
周工说:“那我给你讲讲。串口是字节流,没有消息边界。如果发送方一次发了100个字节,接收方可能分三次收到,也可能一次收到两个消息。这就是粘包和拆包。”
他在纸上画了一个图:
发送方: [消息1: 10字节][消息2: 10字节]
接收方: [5字节] [10字节] [5字节] ← 第一次收到5字节,第二次收到10字节,第三次收到5字节
“解决方案是定义一个协议。比如前4个字节是消息长度,后面是消息内容。收到数据后先读长度,再按长度读内容。”
private readonly List<byte> _buffer = new List<byte>();
private void OnDataReceived(object sender, SerialDataReceivedEventArgs e)
{
var sp = (SerialPort)sender;
var bytesToRead = sp.BytesToRead;
var buffer = new byte[bytesToRead];
sp.Read(buffer, 0, bytesToRead);
_buffer.AddRange(buffer);
// 循环解析,直到缓冲区数据不够一条完整消息
while (_buffer.Count >= 4)
{
// 前4字节是消息长度
int messageLength = BitConverter.ToInt32(_buffer.ToArray(), 0);
// 如果缓冲区数据不够一条完整消息,等待下次接收
if (_buffer.Count < 4 + messageLength)
break;
// 提取消息内容
var message = _buffer.GetRange(4, messageLength).ToArray();
_buffer.RemoveRange(0, 4 + messageLength);
// 处理消息
ProcessMessage(message);
}
}
周工说:“这只是最简单的长度前缀协议。实际项目里还要考虑:字节序问题(大端还是小端)、校验位(CRC)、超时重传、心跳机制。你如果要做上位机,这些都得慢慢学。”
我听完,感觉自己之前做的串口助手就是个玩具。
五、多线程与异步的追问
周工问:“上位机里,UI线程不能做耗时操作,你知道怎么处理吗?”
我说:“用async/await。”
周工说:“那你写一个读取PLC数据的例子。”
我写:
private async void OnReadButtonClick()
{
var data = await _plcService.ReadAsync("DB1.DBD0");
DeviceData = data;
}
周工看了一眼,说:“async void?你知道async void和async Task的区别吗?”
我说:“async void不能await?”
周工说:“不只是不能await。async void的异常无法被捕获,会直接抛到同步上下文,在WPF里就是直接崩溃。所以除了事件处理器,其他地方一律用async Task。”
// ❌ async void:异常无法捕获,程序崩溃
private async void OnReadButtonClick()
{
var data = await _plcService.ReadAsync("DB1.DBD0");
DeviceData = data;
}
// ✅ async Task:异常可以被调用方捕获
private async Task OnReadButtonClickAsync()
{
try
{
var data = await _plcService.ReadAsync("DB1.DBD0");
DeviceData = data;
}
catch (Exception ex)
{
ErrorMessage = "读取失败,请重试";
_logger.Error(ex, "读取PLC异常");
}
}
周工又问:“那如果我要在后台线程更新UI,怎么做?”
我说:“用Dispatcher.Invoke?”
周工说:“对,但更推荐用Dispatcher.InvokeAsync,它是异步的,不会阻塞后台线程。不过如果你用async/await,大部分情况下不需要手动调Dispatcher,因为await会自动回到UI上下文。”
// 后台线程更新UI
private async Task ReadDataAsync()
{
// await之前的代码在UI线程
IsReading = true;
// await之后的代码自动回到UI线程(因为SynchronizationContext)
var data = await Task.Run(() => _plcService.Read("DB1.DBD0"));
// 这里已经在UI线程了,可以直接更新绑定属性
DeviceData = data;
IsReading = false;
}
六、面试结束:周工给我上了一课
面试最后,周工问我:“你还有什么想问的吗?”
我想了想,说:“周工,我今天面试表现不太好,很多问题都没答上来。您能不能给我一点建议,我回去应该怎么补?”
周工看了我一眼,说:“你转行自学能走到这一步,已经很不容易了。我给你几个建议吧。”
他拿起笔,在纸上写了几个词:
1. 别只跟着教程敲,要自己造轮子
“你看的视频教程,都是别人把坑填好了给你看的。你跟着敲一遍,感觉会了,但其实你只是复制了别人的思路。你要自己从零开始,做一个完整的小项目,遇到问题自己查、自己解决,这样才会真正成长。”
2. 框架要学,但别只学API
“Prism、EF Core这些框架,你要学的不是某个方法怎么调用,而是它背后的设计思想。比如EventAggregator为什么能解耦模块?EF Core的DbContext为什么不能是单例?AsNoTracking()为什么能提升性能?理解了这些,你才能举一反三。”
3. 面试被问住很正常,但要会‘复盘’
“你今天被问住的这些问题,回去查清楚,写下来。下次面试再遇到,你就能答上来了。面试本身就是最好的学习机会。”
4. 上位机不只是拖控件
“很多人以为上位机就是拖拖控件、写写事件。真正做起来,你要考虑通信稳定性、数据一致性、多线程安全、异常处理、日志记录。这些才是区分‘会写’和‘能上生产’的关键。”
我听完,心里挺感动的。虽然面试没过,但周工这番话比我在B站上看一个月的教程都值钱。
七、回去之后,我花了三个月补课
面试结束后,我花了整整三个月,把周工问到的那些问题一个一个补上。
Prism方面:
我把Prism的官方文档从头到尾看了一遍,自己写了一个小项目,用Module拆分功能,用EventAggregator做模块间通信,用Region做页面导航。搞懂了keepSubscriberReferenceAlive参数的作用——如果设为false,Prism会用弱引用持有订阅者,不会阻止垃圾回收,但需要手动保留SubscriptionToken来取消订阅。
EF Core方面:
我建了一个小型的设备管理数据库,用EF Core写了增删改查。搞懂了AsNoTracking()、AsSplitQuery()、N+1问题、DbContext的生命周期。还专门写了一篇复盘笔记,把面试被问到的五个问题都记了下来。
串口通信方面:
我研究了一下粘包和拆包的问题。原来串口通信是字节流,没有消息边界,如果发送方一次发了100个字节,接收方可能分三次收到。解决方案是定义一个协议:比如前4个字节是长度,后面是数据,收到之后按长度解析。
多线程方面:
我搞懂了为什么async void不能随便用,为什么要用async Task而不是.Result,为什么UI线程不能做耗时操作。这些在上位机开发里太重要了。
八、第二次面试:从“尴尬”到“从容”
三个月后,我又投了一家做工业设备的公司。
面试我的是技术总监,姓陈。问的问题和周工差不多,但我这次答得从容多了。
问Prism的EventAggregator,我不仅说了怎么用,还说了keepSubscriberReferenceAlive的作用,以及为什么要在OnNavigatedFrom里取消订阅。
问EF Core的N+1问题,我不仅说了Include和AsSplitQuery,还说了AsNoTracking和DbContext的生命周期管理。
问串口粘包拆包,我说了协议设计、长度前缀、缓冲区管理。
问多线程,我说了async void和async Task的区别,说了Dispatcher.InvokeAsync和SynchronizationContext。
陈总听完,说:“你基础挺扎实的,什么时候能入职?”
我说:“随时。”
那一刻,我突然想起三个月前周工面试我的场景。如果不是那次尴尬的经历,我可能还在跟着B站教程敲代码,以为自己“会了”。
九、写给同样在转行面试路上的你
如果你也是从别的行业转行过来,正在准备.NET上位机开发的面试,我想对你说:
第一,面试被问住,不丢人。丢人的是被问住之后,回去还不查。
我第一次面试被问得哑口无言,但我把每一个被问住的问题都记下来,回去查清楚、写下来。第二次面试,同样的问题我就能答上来了。
第二,别只跟着教程敲代码,要自己造轮子。
教程里的项目都是“理想环境”,真实项目里全是坑。你自己从零做一个完整的小项目,遇到问题自己解决,这才是真正的成长。
第三,框架要学思想,不只是学API。
Prism的EventAggregator为什么能解耦模块?EF Core的DbContext为什么不能是单例?AsNoTracking()为什么能提升性能?理解了这些“为什么”,你才能举一反三。
第四,上位机不只是拖控件。
通信稳定性、数据一致性、多线程安全、异常处理、日志记录——这些才是区分“会写”和“能上生产”的关键。
我是豪彡,从建筑设计院跑路转行C#上位机开发。
如果你也在转行的路上,或者正在准备上位机开发的面试,可以关注我。这里没有套路,只有我一路走来踩过的坑和学到的东西,慢慢更新,希望对你有一点用。
下一篇我会写写《转行第一年,我是怎么从“被面试官问懵”到“能独立对接设备”的》,感兴趣的话,咱们慢慢聊。
一起加油。 💪

579

被折叠的 条评论
为什么被折叠?



