简介:一套面向公司年会、团建活动现场的 C# 照片抽奖程序源码,适合需要快速搭建大屏抽奖环节的行政人员与 C# 初学者。程序从指定文件夹读取员工照片并随机滚动展示,支持回车键或鼠标单击暂停与继续,抽中后自动将照片移入中奖目录,避免重复参与;同时提供开始与中奖音效、随时替换音乐、F11 全屏、A 键查看中奖名单、自定义背景,以及 image 或 winner 目录为空时的提示,现场可直接运行,也便于按需二次修改。资源包共 90 个文件,约 10.7MB,包含 C# 源码、项目与配置文件、WinForms 窗体资源、可执行程序、jpg 图片素材和 mp3 音频等,覆盖编译、运行与素材替换所需内容。目前已有 623 人学习下载,可帮助读者理解桌面抽奖程序的图片轮播、按键交互和中奖名单管理等实现思路。
1. 年会照片抽奖程序:两百张照片、一块大屏和一次不能重来的三秒钟
公司年会照片抽奖程序,难的从来不是随机数。某公司年会前一晚,行政把两百多张员工照片拷进 U 盘,扔给技术同事一句话:明晚八点要抽,大屏投出来,抽过的人不能再出现。表格软件里拉个随机函数撑不住这个场面——台下要看照片墙滚起来,停下来的那一刻要定格在中奖那张脸上,一二三等奖还得分开抽。这份 C# 源码解决的就是这一整套:照片按姓名命名丢进目录,程序自动生成照片墙,点开始滚动、点停止定格,支持多轮多奖项、双屏投影、结果导出。适合被临时抓壮丁、要在两三天内交出能上台面的年会互动程序的后端或客户端开发,也适合想拿一个结构完整的小项目练手的 C# 新手。
2. 技术选型与运行环境:WinForms + .NET Framework 的取舍
这份源码是 WinForms 桌面程序。放到今天看有点“老派”,但年会抽奖这种场景恰恰吃这一套:交付周期以天计,运行环境不可控,动画只需要“快”不需要“炫”。真到现场,能不能在行政那台装了半屏办公软件的笔记本上一次点亮,比用了什么框架重要得多。下面把选型理由、跑起来的最小环境和目录结构一次讲清。
2.1 现场电脑不归你管,运行环境要按最坏情况选
年会现场的电脑通常是行政或前台的办公本,系统是家庭版,没装过任何开发工具,也不一定有管理员权限。你抱着自己的开发机调得好好的,拷过去双击打不开,这种事在年会前夜发生过太多次,算是这类项目最常见的翻车点之一。选型的第一原则不是先进,是“对方机器上本来就有”。
.NET Framework 4.7.2 的好处就在这里:Win10 1803 之后它是系统组件,Win7 SP1 打完常规补丁也基本都有 4.6.2 以上,拷个 exe 过去就能双击运行。而 .NET 6/8 的桌面运行时需要单独安装,现场断网、没有管理员权限、装到一半弹 UAC,任何一个环节卡住都收不了场。WinForms 的 GDI+ 画个照片墙绰绰有余,几十上百个缩略图重绘根本到不了性能瓶颈,真正拖慢速度的是每帧重新解码原图,这一点第 3 章会展开。
WPF 的动画确实好看,但 XAML 绑定调试、发布体积、不同机器上的字体渲染差异,都会拉长交付周期;网页方案则要处理浏览器全屏、双屏拖拽、本地文件读取权限,现场还要教主持人怎么操作。几种常见方案摊开对比,结论比较清楚:
| 方案 | 现场部署依赖 | 上手成本 | 双屏支持 | 适合场景 |
|---|---|---|---|---|
| WinForms + .NET Framework 4.7.2 | 系统自带运行时 | 低 | Screen.AllScreens直接可用 | 临时交付、现场不可控 |
| WPF + .NET 8 | 需装运行时或自包含发布 | 中 | 需手写窗口定位与 DPI 适配 | 动画要求高、周期充裕 |
| 网页 + 浏览器全屏 | 需本地服务与浏览器权限 | 中 | 要用户手动拖到副屏 | 远程协作、多端观看 |
我一般会在这个表里直接选第一行,然后把省下来的时间花在现场彩排上。毕竟抽奖程序的成败不在代码写得多漂亮,在主持人喊“停”的那一秒,画面有没有跟上。
2.2 从源码到跑起来:环境清单与第一次编译
拿到源码包后,按下面几步走,基本上十分钟内能看到窗口。
- 安装 Visual Studio 2022 社区版,工作负载里勾选“.NET 桌面开发”,这一项会把 WinForms 设计器和 .NET Framework 目标包一起装上。
- 双击打开解决方案文件,右键解决方案节点选择“还原 NuGet 包”。名单导入依赖 NPOI 读 xlsx,没还原会一片红。
- 打开主程序项目属性,确认“目标框架”是 .NET Framework 4.7.2;如果本机没装这个目标包,VS 会提示下载,跟着点就行。
- 按 F5 启动调试。首次运行如果没有照片,程序会退化成用姓名首字母生成的占位头像,这是刻意设计的,避免演示时直接崩。
项目文件里几个关键节点值得留意,改配置时基本都在这:
<!-- PhotoLottery.App.csproj 关键节点 --> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion> <PlatformTarget>AnyCPU</PlatformTarget> <Prefer32Bit>false</Prefer32Bit> <ApplicationManifest>app.manifest</ApplicationManifest> <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> </PropertyGroup> <ItemGroup> <!-- 读 xlsx 用;版本按还原时的最新稳定版即可 --> <PackageReference Include="NPOI" /> </ItemGroup>TargetFrameworkVersion决定运行时,不要随手改成 4.8,老机器上没装 4.8 就启动不了;Prefer32Bit设为 false 是为了让程序在 64 位系统上吃到更大的地址空间,照片墙加载几百张缩略图时更从容;AutoGenerateBindingRedirects在混用多个依赖版本时能省掉一堆“找不到程序集”的报错;app.manifest里声明 DPI 感知,双屏坐标才不会偏,这一条后面第 4 章还会提。
如果不想开 IDE,命令行也能出包:
# 在解决方案所在目录执行,输出到各项目的 bin\Release msbuild PhotoLottery.sln /t:Restore,Build /p:Configuration=Release /p:Platform="Any CPU"/t:Restore,Build表示先还原再编译,一条命令搞定;/p:Configuration=Release关掉调试符号并开启优化,现场跑 Release 比 Debug 流畅;/p:Platform="Any CPU"要和解决方案里配置的平台名完全一致,写成AnyCPU会报平台不存在。编译产物是整个bin\Release目录,拷到现场机器时要把依赖 DLL 一起带上,别只拷 exe。
常见报错里,MSB3644是没装 4.7.2 的目标包,去 VS 安装器里补;NU1101是 NuGet 源连不上,换一个能访问的源或提前把包缓存拷过去;运行时报“未能加载文件或程序集”,九成是只拷了 exe 没拷 DLL,或者目标机器缺少对应的 C++ 运行库。
2.3 目录结构:哪个文件夹管哪件事
源码的目录划分比较克制,没有为了分层而分层,但把“纯逻辑”和“界面”切开了:
PhotoLottery/ ├─ PhotoLottery.sln ├─ src/ │ ├─ PhotoLottery.App/ # WinForms 主程序 │ │ ├─ Forms/ │ │ │ ├─ MainForm.cs # 操作台:奖项设置、开始/停止 │ │ │ ├─ StageForm.cs # 副屏投影窗体,无边框 │ │ │ └─ SettingsForm.cs # 照片目录、奖项、音效配置 │ │ ├─ Controls/ │ │ │ └─ PhotoWallPanel.cs # 自绘照片墙,开双缓冲 │ │ ├─ Services/ │ │ │ ├─ DrawEngine.cs # 抽奖核心:洗牌、抽人、去重 │ │ │ ├─ PhotoRepository.cs # 照片扫描与缩略图缓存 │ │ │ ├─ RosterImporter.cs # xlsx / CSV 名单导入 │ │ │ └─ ResultExporter.cs # 结果导出与断点续抽 │ │ └─ Program.cs │ └─ PhotoLottery.Core/ # 纯逻辑类库,不引用 WinForms │ ├─ Participant.cs │ ├─ Prize.cs │ └─ ShuffleBag.cs ├─ assets/ │ ├─ photos/ # 员工照片,按姓名命名 │ ├─ sounds/ │ └─ bg/ └─ data/ ├─ roster.xlsx ├─ winners.csv └─ state.jsonCore这个类库不引用任何 WinForms 类型,ShuffleBag和Participant拿到控制台程序、Web API 里都能直接用,这也是后面做二次开发最值钱的部分。Services目录承载了所有和“业务”相关的动作,窗体只负责把结果画出来。data目录是运行期产物,winners.csv每抽出一人就追加一行,state.json记录当前进行到哪个奖项、哪些人已经中过。assets/photos建议直接放按姓名命名的原图,程序启动时统一缩略,不要提前手工压一遍,现场换人换照片的时侯会很麻烦。
3. 核心链路拆解:一次「开始—停止」背后的洗牌袋、Timer 和图片句柄
从点击“开始”到画面定格,程序内部其实只做三件事:从池子里定出中奖者、让照片墙滚起来、把结果落盘。很多同类小工具把这三件事揉在一个窗体事件里,改起来很痛苦;这份源码把它们拆进了DrawEngine、PhotoWallPanel和ResultExporter,下面挨个拆。
3.1 抽奖引擎:先用洗牌袋,别用 Random.Next 硬抽
最直觉的写法是每次random.Next(list.Count)取一个人,取完把这个人从列表里删掉。这个写法在两百人规模下不会出错,但有两个隐患:一是每次new Random()的种子来自系统时钟,同一毫秒内连续 new 两次会得到完全相同的序列,舞台上就出现“连抽三个人都是同一张脸”的玄学;二是删除元素后索引整体前移,逻辑一多就容易越界。更稳的做法是洗牌袋(Shuffle Bag):把池子洗一遍,按顺序往外取。
// ShuffleBag.cs —— 抽奖池:局部洗牌 + 顺序出队,保证一轮内不重复 public sealed class ShuffleBag<T> { private readonly List<T> _source; // 原始名单,抽奖过程中不改动 private readonly List<T> _pool; // 当前剩余池 private readonly Random _rng; // 全程序共享的实例,避免同毫秒种子相同 private int _cursor; // 剩余可用数量,有效索引是 0.._cursor-1 public ShuffleBag(IEnumerable<T> source, Random rng) { _source = source.ToList(); _pool = new List<T>(_source); _rng = rng ?? throw new ArgumentNullException(nameof(rng)); _cursor = _pool.Count; } public int Remaining => _cursor; // 取出一个;池子抽空后自动重洗,对应“所有人都中过一轮”的极端情况 public T Draw() { if (_cursor == 0) Refill(); int idx = _rng.Next(_cursor); // 只在未使用区间里随机 _cursor--; // 把选中项换到已使用区间的尾部,避免整体移动数组 (_pool[idx], _pool[_cursor]) = (_pool[_cursor], _pool[idx]); return _pool[_cursor]; } private void Refill() { _pool.Clear(); _pool.AddRange(_source); _cursor = _pool.Count; // Fisher-Yates 全量洗牌,保证重置后分布均匀 for (int i = _cursor - 1; i > 0; i--) { int j = _rng.Next(i + 1); (_pool[i], _pool[j]) = (_pool[j], _pool[i]); } } }Draw里每次只做一次交换,时间复杂度 O(1),抽一千人也感觉不到停顿;Refill才做完整的 Fisher-Yates,只在池子见底时触发。参数source传的是已经剔除过历史中奖者的参与者集合,rng必须是全程序唯一实例——.NET Framework 没有Random.Shared,我一般会在Program.cs里建一个静态只读的Random,或者干脆给DrawEngine加一把锁,保证多线程调用时不出乱子。
真正决定“谁中奖”的是这个引擎,不是界面。顺序很重要:点击“开始”时先把中奖者算出来放进变量,滚动动画只是把这个结果演出来。反过来做——让画面停在哪张就谁中——一旦中途有人碰了鼠标或者 Timer 掉帧,结果就不可复现,事后有人质疑你连解释的余地都没有。
3.2 滚动动画:Timer 驱动与「停在哪张」的落点控制
照片墙的滚动不需要真的一张张移动控件,那样每帧都在创建销毁PictureBox,GC 压力大到肉眼可见。这里的做法是:控件位置固定,只切换“高亮索引”,用边框和缩放做出滚动感。定时器用 WinForms 自带的System.Windows.Forms.Timer,Tick 回到 UI 线程,省掉跨线程Invoke。
// MainForm.Rolling.cs —— 滚动动画:定时换高亮,停止时缓动定格 private readonly Timer _rollTimer = new Timer { Interval = 60 }; // 60ms 约 16 帧/秒 private int _rollIndex; private void OnRollTick(object sender, EventArgs e) { // 只改高亮索引并局部重绘,不重建任何控件 _rollIndex = (_rollIndex + 1) % _wall.Count; _wall.Highlight(_rollIndex); } private void btnStart_Click(object sender, EventArgs e) { _rollIndex = 0; _wall.ClearHighlight(); _rollTimer.Start(); } private void btnStop_Click(object sender, EventArgs e) { _rollTimer.Stop(); // 先定结果,再让画面滚到对应位置,顺序不能反 var winner = _engine.DrawOne(_currentPrize); _wall.StopAt(winner.PhotoPath, 400); // 400ms 缓动,视觉上“减速停下” _results.Add(winner); ResultExporter.AppendWinner(_csvPath, winner); // 立刻落盘,见 4.3 }Interval设 60ms 是折中值,低于 30ms 在低配办公本上会掉帧,高于 100ms 滚动会一顿一顿;StopAt的第二个参数是缓动时长,400ms 左右最像老虎机停下来那一下,太短显得突兀,太长主持人会尴尬地等。_wall.Highlight内部只对上一个和当前两个矩形调Invalidate,不要写Invalidate(true),那会递归重绘整个窗体,照片一多直接卡成 PPT。
还有一个细节:Timer 的 Tick 是排队执行的,如果某次 Tick 里干了重活(比如同步读了原图),后面的 Tick 会堆积,表现出来就是“点停止后画面还在滚”。所以 Tick 里只允许做索引运算和一次局部重绘,任何 IO 都不许进来。
3.3 照片加载:为什么 Image.FromFile 会把文件锁死
Image.FromFile是最顺手的加载方式,也是这类程序最容易踩的坑:它内部以FileShare.Read打开文件并一直持有句柄,只要图片还显示在界面上,你就删不掉、改不了名,现场想临时换一张照片,系统直接弹“文件正被另一进程使用”。正确姿势是自己开FileStream,读进内存后再交给 GDI+。
// PhotoRepository.cs —— 不锁文件地加载图片,并统一缩到缩略图尺寸 public static Image LoadUnlocked(string path, int maxEdge) { using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) using (var origin = Image.FromStream(fs, false, false)) { // 手机竖拍照片带 EXIF 方向标记,不摆正会在墙上躺倒 using (var upright = ExifOrientation.Apply(origin)) { return Thumbnail.Make(upright, maxEdge); // 长边压到 maxEdge,降低句柄压力 } } }FileShare.ReadWrite是关键参数,允许其他进程同时读写这个文件,程序就不会独占;Image.FromStream的第二个参数关掉颜色管理,第三个参数跳过图像数据校验,能明显加快批量加载。maxEdge建议 320 到 480 之间,照片墙一格才一百多像素,加载原图纯属浪费——一张四千万像素的手机照解码要几十毫秒,两百张就是好几秒的启动时间。
这里还有个语言版本的坑:.NET Framework 4.7.2项目默认 C# 7.3,上面那种using var的声明式写法编译不过,要么在 csproj 里把LangVersion提到 8.0,要么老老实实写using块。另外 GDI+ 的 Image 对象是稀缺资源,每个进程大约一万个 GDI 对象的上限,缩略图不Dispose的话,滚十分钟就该报“一般性错误”了。我一般的做法是启动时一次性把所有缩略图加载
本文还有配套的精品资源,点击获取