ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# WinForms年会照片抽奖源码:防重复、缩略图缓存与双屏适配

C# WinForms年会照片抽奖源码:防重复、缩略图缓存与双屏适配

简介:这是一套面向企业行政、活动策划人员及C#初学者的年会照片抽奖程序源码,解决现场抽奖环节需要随机滚动照片、烘托气氛并记录中奖名单的实际问题。程序支持读取指定文件夹内的图片并高速随机切换,按下回车键或单击鼠标即可暂停与继续,抽中者照片会自动移入winner目录从而不再参与后续抽取,全部抽完或名单为空时均有提示;同时集成开始、中奖欢呼与结束三类音乐,可随时替换音乐名称,并支持F11全屏、A键查看全部中奖人员及自定义背景图。资源包共90个文件,约10.7MB,包含12个cs源文件、25张jpg图片素材、3段mp3音效、6个可执行程序及sln与csproj工程文件,另附config配置、resources资源与resx界面定义,结构完整可直接编译运行。目前已有623人学习下载,适合希望快速复刻抽奖效果或在此基础上二次开发的学习者参考。

1. 年会大屏前的照片抽奖:C#源码能解决什么

年会现场大屏正滚着照片,行政临时塞过来一个U盘说“这些头像能不能直接抽”。这类公司年会照片抽奖程序 C#源码解决的就是这个场景:把员工照片放进目录,程序扫描成参与者池,滚动展示、随机定格、按奖项去重,最后把中奖名单落盘。它不是简单的“随机数+图片框”,现场最怕重复中奖、图片加载卡顿、双屏分辨率错位。

适合谁?手里有 C# 基础、要在半天内改出可用抽奖页面的开发者,或者想给行政做一套可复用活动工具的技术同事。源码是 WinForms 思路,控件直接拖、事件直接写,不需要先搭前端工程,也不依赖现场网络。你拿到后先跑通默认照片目录,再换自己的奖项配置,基本就能上台用。

2. 技术选型与项目骨架:WinForms + 分层目录怎么搭

2.1 为什么选 WinForms 而不是 WPF/Web

年会抽奖程序的运行环境通常很“土”:一台临时借来的笔记本、一个投影仪、一个照片文件夹,可能还没有网络。WinForms 在这种场景下优势很直接,PictureBox显示照片、Timer做滚动、Screen.AllScreens找副屏、FolderBrowserDialog选目录,全是现成控件。现场改一个按钮位置,拖一下就能编译,不用先理解 XAML 的绑定和动画时间线。

WPF 的动画确实更顺滑,做缩放、淡入淡出更漂亮,但代价是你要处理Dispatcher、样式模板和不同 DPI 下的布局。Web 方案看起来“跨平台”,实际要起服务、配浏览器全屏、防止误触地址栏,现场网络一断就尴尬。这不是说 WinForms 更先进,而是交付时间只有半天时,它翻车概率最低。常见做法是 WinForms 做壳,业务逻辑抽到几个服务类里,别把所有代码塞进Form1.cs。

我一般会把项目分成四层:Forms只负责按钮、标签、图片框和用户交互;Services放抽奖、照片加载、结果导出;Models放参与者和奖项;Infrastructure放配置读取和序列化。这样二次开发时,改抽奖规则不用碰界面,换皮肤也不用动算法。下面这张对比表是选型时最常被问到的几个点:

方案现场部署动画能力改造成本适合场景
WinForms拷贝文件夹即可,依赖少够用,靠 Timer 和图片切换低,事件驱动直观单机、离线、快速交付
WPF需要对应运行时强,适合复杂动效中高,XAML 和绑定要熟有设计资源、追求视觉效果
Web 页面要起服务或打包浏览器强,CSS 动画灵活中,前后端都要管多端访问、远程协作

真正要警惕的是“所有逻辑都写在按钮事件里”。抽奖按钮里直接new Random()、直接读文件、直接拼导出字符串,第一版能跑,第二天加一个“三等奖抽完再抽二等奖”就会乱。分层不是摆样子,是为了让你在現場改规则时只改一个类。

2.2 源码目录结构与启动链路

这份 C# 源码的骨架不复杂,但目录要能一眼看出东西放哪。下面是我拆这类项目时最常见的文件清单,你可以对照手里的源码看是否一致:

路径职责二次开发时改哪里
Program.cs程序入口,加载配置,启动主窗体换启动参数、加异常兜底
Forms/MainForm.cs主界面,绑定照片、按钮、Timer改布局、加奖项页签
Forms/PrizeForm.cs奖项设置或中奖展示改奖项字段、加动画
Models/Participant.cs参与者模型,照片路径、工号、姓名加部门、座位号
Models/Prize.cs奖项模型,名称、人数、是否允许重复加奖品图片、抽奖顺序
Services/PhotoService.cs扫描照片、生成缩略图、缓存改命名规则、加格式
Services/LotteryService.cs抽奖池、随机抽取、去重改权重、改分组
Services/ResultService.cs中奖结果落盘、导出改 CSV 字段、加时间戳
Infrastructure/ConfigStore.cs读写config.json加配置项、做默认值
Assets/Photos员工照片目录换成现场照片
config.json照片目录、导出目录、奖项列表现场前改路径和奖项

启动链路通常是:Program.Main读配置,MainForm构造时扫描照片目录,PhotoService返回参与者列表,LotteryService用这个列表初始化抽奖池,最后Timer只负责滚动展示。这个顺序很重要,照片没加载完就允许点“开始”,抽奖池可能是空的,现场就会出现“抽不出来”的尴尬。

static class Program { [STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); // 配置文件放在 exe 同目录,现场换电脑时整包拷贝即可 var config = ConfigStore.Load("config.json"); // 主窗体只接收配置,不在这里读照片,避免启动时卡死 Application.Run(new MainForm(config)); } }

这段入口代码的关键参数是STAThread和配置路径。STAThread不是可有可无的装饰,文件对话框、剪贴板、部分图片解码都依赖单线程单元,去掉后可能在现场选目录时直接抛异常。config.json用相对路径,程序会按当前工作目录找,实际项目里更稳的写法是Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config.json"),这样从桌面快捷方式启动也不会找错位置。

构建和运行可以按下面的顺序走,不要一上来就双击 exe:

# 先还原依赖,再编译 Release,最后到输出目录运行 nuget restore AnnualLottery.sln msbuild AnnualLottery.sln /p:Configuration=Release # 输出目录通常在 bin\Release 下,把 config.json 和 Assets 一起拷过去

nuget restore只在你拿到的是带包引用的解决方案时需要;如果源码里没有外部包,直接编译即可。/p:Configuration=Release是为了让现场跑的是优化后的版本,别拿 Debug 输出上台,异常对话框和性能都不适合大屏。编译完先检查输出目录里有没有config.json、Assets/Photos和占位图,缺一个都可能在启动时弹错。

2.3 运行前的环境与素材准备

环境上,目标框架按源码里已有的 .NET Framework 4.x 走,不要手贱升到新版本再怪PictureBox行为不一致。开发机装好对应的开发工具,现场机装好运行时;如果现场机不能装环境,就提前确认源码是否支持自带运行时或改成单文件发布。字体也要提前看,抽奖大屏常用粗体大字,临时换机器可能没有同款字体,姓名标签会换行或截断。

素材准备比代码更容易翻车。照片目录建议只放一层,不要嵌套子目录,文件名用“工号_姓名.jpg”这种可解析格式;没有工号就统一“姓名.jpg”,但要注意重名。格式优先jpg、jpeg、png、bmp,聊天软件导出的特殊格式先批量转掉。下面这个配置类负责把现场参数收口,改路径和奖项不用重新编译:

public sealed class AppConfig { // 照片目录:建议用绝对路径,现场换电脑时只改这一处 public string PhotoDir { get; set; } // 导出目录:中奖名单和日志都写这里,别写系统盘根目录 public string ExportDir { get; set; } // 奖项列表:Name 是显示名,Count 是该奖项人数 public List<Prize> Prizes { get; set; } = new List<Prize>(); public static AppConfig Load(string path) { if (!File.Exists(path)) { // 首次运行给一套默认值,避免直接崩溃 return new AppConfig { PhotoDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets", "Photos"), ExportDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Results"), Prizes = new List<Prize> { new Prize { Name = "三等奖", Count = 5, AllowRepeat = false }, new Prize { Name = "二等奖", Count = 3, AllowRepeat = false }, new Prize { Name = "一等奖", Count = 1, AllowRepeat = false } } }; } // 具体序列化实现按源码里的来,这里只表达配置加载的边界 return ConfigStore.Deserialize<AppConfig>(File.ReadAllText(path)); } }

这段代码里PhotoDir和ExportDir是现场最常改的两个参数。照片目录不要写死成开发机路径,导出目录要提前建好并确认有写权限。Prizes里的AllowRepeat控制同一个人能否重复中奖,年会大多数规则是不允许,所以默认false。如果源码没有配置类,而是把奖项写死在按钮里,建议先抽出来,不然后面加“特等奖”要改多个地方。

提示:正式上台前,用 20 到 30 张测试照片完整跑一遍“开始滚动、停止定格、导出名单”,别只测试一张照片。

3. 照片加载与缩略图缓存:几百张人像不卡顿的写法

3.1 照片目录扫描与命名约定

照片抽奖的第一个性能瓶颈不在随机算法,而在图片加载。几百张手机照片动辄两三兆,如果启动时在 UI 线程里逐张Image.FromFile,窗口会白屏好几秒,现场主持人会以为程序挂了。正确的做法是先只扫描文件路径和姓名,把缩略图加载放到后台,并且做缓存。扫描阶段不碰像素数据,速度很快。

命名约定要提前和行政对齐。最常见的是“工号_姓名.jpg”,解析时取第一个下划线后面的部分作为显示名;没有下划线就把整个文件名当姓名。遇到“张三(1).jpg”这种重复文件,最好在扫描时记录日志,别让两个人同名同姓在台上分不清。下面这段扫描代码只做三件事:过滤扩展名、解析显示名、按姓名排序:

public sealed class PhotoService { private readonly string[] _allowedExts = { ".jpg", ".jpeg", ".png", ".bmp" }; public List<Participant> Scan(string photoDir) { var list = new List<Participant>(); if (!Directory.Exists(photoDir)) { throw new DirectoryNotFoundException($"照片目录不存在:{photoDir}"); } // 只扫顶层,避免把缩略图缓存目录或子文件夹里的旧照片带进来 foreach (var file in Directory.EnumerateFiles(photoDir, "*.*", SearchOption.TopDirectoryOnly)) { var ext = Path.GetExtension(file).ToLowerInvariant(); if (!_allowedExts.Contains(ext)) continue; var fileName = Path.GetFileNameWithoutExtension(file); var displayName = ParseDisplayName(fileName); if (string.IsNullOrWhiteSpace(displayName)) continue; list.Add(new Participant { Id = fileName, DisplayName = displayName, PhotoPath = file }); } // 排序后滚动展示更稳定,也方便现场核对人数 return list.OrderBy(p => p.DisplayName, StringComparer.CurrentCulture).ToList(); } private string ParseDisplayName(string fileName) { // 约定:工号_姓名;没有下划线就把整个文件名当姓名 var index = fileName.IndexOf('_'); return index >= 0 && index < fileName.Length - 1 ? fileName.Substring(index + 1) : fileName; } }

SearchOption.TopDirectoryOnly是有意为之,现场照片目录经常混进“已中奖”子文件夹或备份目录,递归扫描会把重复人员带进池子。_allowedExts用白名单而不是黑名单,系统缩略图文件、聊天软件缓存图、特殊格式都会被挡掉。ParseDisplayName的下标判断是为了防止文件名以“_”结尾时取到空字符串。扫描结果按当前文化排序,中文姓名不会出现乱序,现场核对人数时更直观。

如果照片量大,扫描本身仍然可能让窗口停顿,可以在MainForm.Load里先显示“正在扫描照片”,再用Task.Run包一层。常见做法是扫描只返回路径,缩略图在后台逐张补,界面先显示姓名占位,不要求一次把所有头像都画出来。

3.2 异步加载与缩略图缓存代码

缩略图加载有两个坑:一是直接Image.FromFile会锁住原文件,现场想换一张照片会提示“文件被占用”;二是每次滚动都重新解码原图,几百张轮播时 CPU 直接拉满。解决方式是用FileStream以FileShare.Read打开,解码后生成固定尺寸的Bitmap,再放进内存缓存。缓存键用照片路径,抽奖时只取缓存,不碰磁盘。

public async Task<Bitmap> LoadThumbnailAsync(string path, int width, int height, CancellationToken token) { return await Task.Run(() => { // FileShare.Read 允许其他程序同时读,避免锁死原照片 using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) using (var source = Image.FromStream(fs)) { var bmp = new Bitmap(width, height); using (var g = Graphics.FromImage(bmp)) { g.InterpolationMode = InterpolationMode.HighQualityBicubic; g.PixelOffsetMode = PixelOffsetMode.HighQuality; g.SmoothingMode = SmoothingMode.HighQuality; // Max 表示填充裁切,保证头像框铺满;要完整显示改成 Min var scale = Math.Max((float)width / source.Width, (float)height / source.Height); var drawWidth = (int)(source.Width * scale); var drawHeight = (int)(source.Height * scale); // 居中绘制,横图和竖图都不会偏到一边 g.DrawImage(source, (width - drawWidth) / 2, (height - drawHeight) / 2, drawWidth, drawHeight); } return bmp; } }, token); }

这段代码的参数width、height是缩略图目标尺寸,建议按头像框实际像素给,比如 240×320 或 300×300,不要先给 1000 再让控件缩,内存会白涨。scale用Math.Max是“填充”模式,适合人像占满框;如果照片里有半身像不想裁头,改成Math.Min并给背景色。FileShare.Read是血泪经验,现场换照片时不会因为程序开着就复制不进去。CancellationToken用于窗口关闭或重新扫描时取消后台任务,避免退出后还在解码。

缓存建议用ConcurrentDictionary<string, Bitmap>,键是完整路径,值是缩略图。加载时先查缓存,没有再异步加载;加载失败不要抛到 UI 线程,记录路径后显示占位图。下面这段是缓存调用的骨架:

private readonly ConcurrentDictionary<string, Bitmap> _thumbCache = new ConcurrentDictionary<string, Bitmap>(); public async Task<Bitmap> GetThumbnailAsync(Participant p, int w, int h, CancellationToken token) { if (_thumbCache.TryGetValue(p.PhotoPath, out var cached)) return cached; try { var bmp = await LoadThumbnailAsync(p.PhotoPath, w, h, token); _thumbCache[p.PhotoPath] = bmp; return bmp; } catch (Exception ex) { // 单张失败不影响整场,记录后返回占位图 LogHelper.Write($"加载照片失败:{p.PhotoPath},{ex.Message}"); return _placeholder; } }

TryGetValue先查缓存,避免重复解码;_thumbCache[p.PhotoPath] = bmp在单线程 UI 场景够用,如果多线程同时加载,改用AddOrUpdate。_placeholder是提前加载的占位图,尺寸和缩略图一致,失败时界面不会出现红叉。日志里写完整路径,现场排查时能直接定位是哪张照片坏了,不要只写“加载失败”。

3.3 头像框比例与占位图处理

照片比例是现场最容易忽略的细节。员工自拍有竖图、合影有横图、证件照有白边,如果头像框固定 1:1,横图会被裁掉两边,竖图会裁掉头顶。比较稳的做法是头像框统一 3:4,代码里用填充裁切,并且在框内加一层半透明遮罩,让姓名标签始终可读。要是行政坚持不裁人脸,就改成完整显示,左右留背景色,视觉上不如填充饱满,但不会把人的头切掉。

占位图不要用纯色块,最好是一张写着“照片加载中”或“暂无照片”的灰色图,尺寸和缩略图一致。加载失败时直接换占位图,不要弹 MessageBox,现场弹窗会挡住大屏。图片格式支持上,jpg和png最稳,bmp文件大但兼容性好,特殊格式提前批量转换。下面这张表可以作为素材检查清单:

检查项建议出问题时的表现
文件格式jpg/jpeg/png/bmp红叉、空白、解码异常
文件命名工号_姓名 或 姓名姓名显示成文件名、重名
照片比例竖图优先,人脸居中裁切后看不到完整人脸
文件大小单张控制在 2MB 内首次加载慢、滚动卡顿
目录层级只放一层重复人员、旧照片混入

注意:正式抽奖前把所有照片跑一遍加载,坏图当场换掉,不要等到台上滚到那一张才发现是红叉。

4. 抽奖核心链路:随机算法、去重与滚动动画

4.1 奖项配置与参与者池

抽奖程序的业务核心不是“随机”,而是“池子”和“规则”。参与者池初始化时把所有照片对应的人放进去,每抽中一个就从池子里移除,天然避免重复中奖。如果业务允许同一个人中多个奖项,就不能物理移除,而是维护一个全局中奖集合,抽之前过滤。年会大多数规则是“一人一奖”,所以物理移除最简单,也最不容易出错。

奖项配置至少要有名称、人数、是否允许重复、抽取顺序。下面这两个模型类是后面所有逻辑的基础:

public sealed class Participant { public string Id { get; set; } // 工号或文件名,用于去重 public string DisplayName { get; set; } // 大屏显示姓名 public string PhotoPath { get; set; } // 原图路径 } public sealed class Prize { public string Name { get; set; } // 奖项名称,如“三等奖” public int Count { get; set; } // 该奖项人数 public bool AllowRepeat { get; set; } // 是否允许已中奖者再抽 }

Id不要用显示姓名,重名时会把两个人当成一个人。Count是计划人数,实际抽取时还要受池子剩余人数限制,池子空了要提示“没有可抽人员”,不能继续Next。AllowRepeat为false时,抽中后从池子移除;为true时保留池子,但用集合记录已中奖,展示时加标记。常见做法是默认false,特等奖需要“全场重新抽”时才临时改配置。

4.2 防重复的随机抽取代码

随机数本身不是玄学,重复中奖多半是池子没移除,或者每次点击都new Random()导致同一毫秒种子相同。正确写法是Random只创建一次,抽取时加锁,抽中后立即从池子删除。下面这段LotteryService把池子和随机源都收在类里,外部只调用DrawOne:

public sealed class LotteryService { private readonly List<Participant> _pool; private readonly Random _random = new Random(Guid.NewGuid().GetHashCode()); private readonly object _sync = new object(); public LotteryService(IEnumerable<Participant> all) { _pool = all.ToList(); } public Participant DrawOne() { lock (_sync) { if (_pool.Count == 0) return null; var index = _random.Next(_pool.Count); var winner = _pool[index]; // 中奖即移出池,天然防重复;允许重复的奖项不要走这个方法 _pool.RemoveAt(index); return winner; } } public int Remaining => _pool.Count; }

_random用Guid.NewGuid().GetHashCode()做种子,是为了避免程序在同一秒重启时产生相同序列,虽然概率低,但现场重启一次就中招会很尴尬。lock防止 Timer 重入或用户连点导致两个线程同时修改_pool。RemoveAt是 O(n),几百人规模完全够用,不值得为这点性能引入链表。Remaining暴露剩余人数,界面可以显示“剩余 128 人”,行政心里有数。

如果奖项允许重复,不要直接改DrawOne,而是另写一个DrawOneAllowRepeat,用HashSet<string> _wonIds记录已中奖,抽之前过滤。这样两条规则互不影响,二次开发时不会把“一人一奖”的场次改坏。

4.3 Timer 滚动与定格逻辑

滚动动画的本质是快速切换图片,不是每次 Tick 都抽一次奖。正确顺序是:点“开始”启动Timer,Tick 里按顺序轮流显示照片;点“停止”先停Timer,再调用DrawOne抽一个人,最后把中奖者照片和姓名定格。这样照片和姓名一定对应同一个人,不会出现“照片是张三、名字是李四”的翻车现场。

private int _rollIndex; private void timerRoll_Tick(object sender, EventArgs e) { if (_participants.Count == 0) return; // 只做展示轮播,不在这里抽奖 var p = _participants[_rollIndex++ % _participants.Count]; picCurrent.Image = _photoService.GetCachedThumbnail(p.PhotoPath); lblName.Text = p.DisplayName; } private void btnStop_Click(object sender, EventArgs e) { timerRoll.Stop(); btnStop.Enabled = false; // 防止连点导致重复抽奖 var winner = _lottery.DrawOne(); if (winner == null) { lblName.Text = "没有可抽人员"; btnStop.Enabled = true; return; } ShowWinner(winner); // 绑定同一张照片和同一个姓名 _resultService.Append(winner, _currentPrize); btnStop.Enabled = true; }

Interval建议 50 到 80 毫秒,太快人眼看不清,太慢像卡顿。_rollIndex取模保证循环,照片列表为空时直接返回。btnStop.Enabled = false是防连点的关键,现场主持人一紧张会连按两下,不加这行就可能一次抽两个人。ShowWinner里只接收winner一个对象,照片和姓名都从它取,避免两个数据源不同步。Append放在定格之后,先让观众看到结果,再落盘,顺序不影响数据,但心理上更稳。

4.4 结果落盘与导出

结果落盘要按“每抽一次写一次”来做,不要等全场结束再统一保存。现场断电、误关窗口、程序崩溃都可能发生,内存里的中奖名单丢了就是黑匣子。最简单的做法是维护一个List<ResultRow>,每次Append后立即序列化到results.json;导出 CSV 时再读这个列表,加 BOM 防止表格软件打开乱码。

public sealed class ResultService { private readonly string _jsonPath; private readonly List<ResultRow> _rows = new List<ResultRow>(); public ResultService(string exportDir) { Directory.CreateDirectory(exportDir); _jsonPath = Path.Combine(exportDir, "results.json"); } public void Append(Participant p, Prize prize) { _rows.Add(new ResultRow { Time = DateTime.Now, PrizeName = prize.Name, ParticipantId = p.Id, DisplayName = p.DisplayName }); // 每抽一次就落盘,现场断电也不丢已出结果 File.WriteAllText(_jsonPath, JsonStore.Serialize(_rows)); } public void ExportCsv(string csvPath) { var lines = new List<string> { "时间,奖项,工号,姓名" }; lines.AddRange(_rows.Select(r => $"{r.Time:yyyy-MM-dd HH:mm:ss},{r.PrizeName},{r.ParticipantId},{r.DisplayName}")); // UTF-8 带 BOM,表格软件打开中文不乱码 File.WriteAllLines(csvPath, lines, new UTF8Encoding(true)); } }

exportDir由配置传入,构造函数里先CreateDirectory,避免现场没有目录导致写失败。Append里写 JSON 是覆盖写,数据量小没问题;如果担心写入中断,可以先写临时文件再替换。ExportCsv的UTF8Encoding(true)是后悔药,不带 BOM 的 CSV 在部分表格软件里会把中文显示成乱码。导出字段不要带照片绝对路径,名单发给行政时路径没有意义,还泄露本机目录结构。

提示:抽奖前先在导出目录放一个空文件测试写权限,现场机经常把结果目录设在只读位置。

5. 现场联调避坑:双屏、权限、字体和照片格式

5.1 双屏投射与分辨率适配

年会现场最常见的是笔记本接投影或大屏,程序要显示在副屏,控制界面留在主屏。WinForms 里用Screen.AllScreens找非主屏,把抽奖窗体设为无边框、最大化、手动定位到副屏。不要用固定坐标1920,0,不同机器主副屏排列不一样,插拔投影后坐标会变。下面这段代码在窗体加载后调用,找不到副屏就回退主屏:

private void MoveToSecondScreen(Form form) { if (Screen.AllScreens.Length < 2) return; var second = Screen.AllScreens.FirstOrDefault(s => !s.Primary); if (second == null) return; form.StartPosition = FormStartPosition.Manual; form.FormBorderStyle = FormBorderStyle.None; form.WindowState = FormWindowState.Maximized; form.Bounds = second.Bounds; }

!s.Primary取副屏,Bounds用副屏完整区域,FormBorderStyle.None去掉标题栏,避免投影上出现关闭按钮。StartPosition.Manual必须在设置Bounds前给,否则窗体可能被系统重新居中。如果现场是复制屏幕而不是扩展屏幕,AllScreens.Length只有 1,代码会回退主屏,这是正常行为,提前和行政确认显示模式即可。

分辨率适配靠AutoScaleMode.Dpi和控件的Anchor、Dock,不要把所有位置写死。大屏字体用系统自带粗体,提前在目标机器上看一遍,缺字体时姓名可能换行。照片框用SizeMode = Zoom或自己绘制的填充模式,避免拉伸变形。现场联调时按“控制屏点开始、大屏看滚动、停止后看定格、导出名单”走一遍,别只在开发机上点。

5.2 五条踩坑记录:现象、原因、解决

第一条,现象是抽奖时照片显示红叉或空白。原因通常是原图被其他程序占用,或者Image.FromFile加载后没有释放,现场想替换同名照片时复制失败。解决方式是用FileStream加FileShare.Read读取,加载失败返回占位图,并把失败路径写日志。不要用Application.DoEvents硬刷新,那会让界面更卡。

第二条,现象是同一个人连续中两个奖。原因多半是只按当前奖项去重,没有维护全局中奖集合,或者抽中后没有从池子里移除。解决方式是“一人一奖”场次直接RemoveAt,允许重复的场次用HashSet<string>记录Participant.Id,抽之前过滤。注意去重键用工号或文件名,不要用显示姓名,重名时会误伤。

第三条,现象是点击“停止”后照片和姓名不是同一个人。原因是 Tick 里更新了界面,停止时又用另一个随机源抽奖,两个数据源各走各的。解决方式是停止时先timerRoll.Stop(),再DrawOne(),拿到winner后照片和姓名都从这个对象取。滚动阶段只做展示,不产生中奖结果,链路就唯一了。

第四条,现象是投影后界面只显示一半,或者按钮跑到屏幕外。原因是窗体尺寸写死、DPI 缩放不一致,或者副屏分辨率比开发机小。解决方式是布局用Dock和Anchor,窗体启动后按Screen.Bounds重新定位,必要时用AutoScaleMode.Dpi。现场提前把投影接上跑一遍,不要等开场前五分钟才插线。

第五条,现象是中奖名单导出后中文乱码,或者照片目录里混入系统缩略图文件导致启动报错。原因是 CSV 用了无 BOM 的 UTF-8,照片扫描没有过滤扩展名。解决方式是new UTF8Encoding(true)写 CSV,照片扫描用白名单.jpg/.jpeg/.png/.bmp,单张加载失败只记日志不阻断整场。导出前先打开一次文件确认列名和中文正常,别直接发给行政。

6. 二次开发:把抽奖程序改成可复用活动工具

6.1 从硬编码到配置化

第一版能跑之后,最值得做的改造是把“照片目录、奖项、导出路径、是否允许重复”全部收进config.json。这样下一次活动不用重新编译,行政自己改奖项人数就能用。进一步可以加一个IActivityMode接口,把“照片抽奖”和“名单抽签”分开实现,主窗体只依赖接口。下面这个小接口就是扩展点:

public interface IDrawMode { string Name { get; } Participant DrawOne(); void Reset(IEnumerable<Participant> participants); }

Name给界面显示模式名,DrawOne返回中奖者,Reset用于换场次或重新开始。照片抽奖实现里维护池子,名单抽签实现里可以按部门权重抽。主窗体只调接口,不加if (mode == "照片")这种分支,后面加“答题抽奖”也不用改 UI。

6.2 验证方法与扩展点

改完配置化后,我一般用 30 张测试照片跑三轮:第一轮抽完检查有没有重复,第二轮中途关掉程序再打开看结果文件是否还在,第三轮把导出 CSV 用表格软件打开看中文和列顺序。双屏验证不能省,必须接一次副屏看定格位置;照片验证要故意放一张坏图,确认程序显示占位图而不是崩溃。扩展点上,优先加“中奖者上台确认”按钮和“撤销上一次抽奖”,现场主持人手滑时比重新跑程序体面。

从那以后我每次接年会现场需求,都强制先在副屏跑一遍、拔一次网线、再抽三轮空奖,确认池子空了会提示而不是抛异常。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进