
这两年但凡需要搞个现场抽奖总能遇到奇怪的需求年会要滚动大屏、课堂要随机点名、门店活动想搞点仪式感结果现成抽奖软件要么带广告弹窗要么把名单上传到别人的服务器上稍微冷门点的系统直接跑不起来。后来我干脆用 Python 自带的 Tkinter 写了个滚动抽奖器不依赖任何第三方库复制一份代码就能在 Windows、macOS、Linux 上跑压缩包整体体积不到 1MB。这篇文章就把从零到能上活动的完整过程拆开讲清楚包括核心机制、完整代码、现场部署的坑以及抽奖记录导出、批量抽取这些进阶玩法。适合两类人看一是活动组织者想快速拿到一个稳定、离线、可定制的抽奖工具二是 Python 学习者想通过一个完整的小项目把 Tkinter、事件循环、文件读写这些基础点串起来。1. 为什么自己做抽奖器现成软件和网页版各有各的尴尬1.1 几个真实场景踩出来的结论最早我也偷懒活动前一天临时搜了一个“XX抽奖器”下载。装完一打开先弹个资讯广告再提示试用版每天只能抽 20 次中奖名单里还自动带上软件推广二维码。这种工具放在活动现场观众看一眼都觉得不专业。后来换网页版抽奖功能确实全但需要现场联网一旦会场 Wi-Fi 卡顿名单加载都成问题更怕的是网页里用的字体和动画在你投影设备上渲染出来完全变了样。Tkinter 方案最让我放心的一点是它完全离线运行。名单就是一个 txt 文本文件程序逻辑全部在本地执行不涉及任何网络请求。Python 自带的标准库 tkinter 在安装 Python 时就会一并装上不需要 pip install 任何依赖。我试过在一台配置很老的双核笔记本上跑滚动切换一点不卡CPU 占用可以忽略不计比市面上那些基于网页动画的方案省资源得多。1.2 三种主流方案的对比选型要看你最在意什么方案依赖条件可定制性现场稳定性适合场景现成桌面抽奖软件需下载对应系统版本低换皮肤要付费中每次更新都可能改逻辑不追求仪式感只用基础抽取网页在线抽奖依赖联网和浏览器中可微调样式受网络影响大允许联网、有技术人员盯场Tkinter 自制脚本仅需 Python 运行环境极高代码全部掌握高本地独立运行网课点名、线下活动、需要现场改逻辑的场景我自己的判断标准很简单活动一旦开始程序不能出问题而且出了问题我得能在 10 分钟内改完代码。现成软件的黑盒问题在于你永远不知道它内部怎么处理名单去重、怎么生成随机数而 Tkinter 版抽奖器从随机数到滚动逻辑全是你自己写的出了问题翻代码就能定位。当然前提是活动前自己完整跑几遍别把没验证过的脚本直接搬上台。下表这几行是多次实战后总结出的关键考量点准备做抽奖器的人可以直接参照。2. 抽奖器最核心的三个机制滚动显示、名单去重与“减速命中”2.1 滚动效果的本质定时器换文字不是动画很多人第一次接触 Tkinter 时会以为要做动画必须用 Canvas 画图、计算帧率。实际上抽奖器要的“滚动”效果本质就是每隔一小段时间把界面上的文字替换成另一个随机名字。这里用到 Tkinter 的after方法它相当于给程序设定一个延迟提醒过多少毫秒后调用一次指定函数。def _roll_step(self): if not self.rolling: return self.current_text random.choice(self.remaining) self.name_label.config(textself.current_text) self.trace_id self.root.after(80, self._roll_step)这段代码里after(80, self._roll_step)表示 80 毫秒后再执行一次自己于是函数会像接龙一样无限循环下去每次循环都把文字换成剩余名单里的随机一个名字。人眼看过去就是名字在高速滚动。滚动间隔 80 毫秒是多次实测后比较舒服的一个值——太快了人会头晕太慢了缺少悬念感。如果现场观众反应冷淡想加快节奏直接把 80 改成 50 就行改动成本几乎为零。注意这里不能用while True死循环去刷文字因为 Tkinter 是事件驱动模型窗口需要持续处理按钮点击、界面刷新等事件。用while True会把主线程占死窗口直接未响应。after的好处就是让出控制权每次定时器到期后再回来更新一次界面其余时间窗口照常响应其他操作。2.2 名单去重逻辑抽过的名字不能第二次上台现场抽奖最忌讳同一个名字被抽两次。我见过有人用random.choice(names)直接抽抽完也不把名字移出去结果第二轮抽中的人恰好是第一轮已经上台的现场立刻冷场。处理这个问题其实只要维护一个“剩余名单”的列表每次抽中就把该名字从列表里移除。self.remaining self.names[:] # 初始化剩余名单 # 抽中之后 self.remaining.remove(name)这里有个很容易被忽略的细节初始化剩余名单时必须用切片self.names[:]拷贝一份而不是直接用self.remaining self.names。否则两个变量会指向同一个列表对象你从remaining里移除名字时原始名单names也会跟着少人。等到你想重新开始下一轮抽奖时会发现名单莫名其妙变短了。这个坑我第一版代码就踩过排查了半天才发现是引用传递的问题。加载名单文件时也应该顺手做一次去重。比如名单 txt 里可能几百行保不齐哪一行重复了。读取时用一个循环判断看到已存在的名字就跳过可以有效避免同一人出现两次导致“中奖概率翻倍”这种低级事故。2.3 停止动作的“减速命中”让现场体验更像那么回事直接一个按钮把滚动硬停下来会让人感觉生硬。一个更稳妥的做法是点击停止后程序先随机抽出一个中奖目标然后不去立刻显示而是再滚动几次逐步减到目标上。这个过程我称它为“减速命中”。观众看到的是名单快速跳动几次后慢慢停在某个人身上悬念感和仪式感都强很多。实现思路也不复杂用一个剩余步数变量来控制def stop_roll(self): self.rolling False if self.trace_id is not None: self.root.after_cancel(self.trace_id) self.trace_id None self.target random.choice(self.remaining) self.settle_steps 5 self._settle_step() def _settle_step(self): if self.settle_steps 0: self.settle_steps - 1 self.current_text random.choice(self.remaining) self.name_label.config(textself.current_text) self.trace_id self.root.after(120, self._settle_step) else: self._mark_result(self.target)这里要注意减速期间的滚动要控制在已抽取名单之外。如果从全部名单里选中间可能闪现出已经中过奖的人会被细心的观众记住。所以减速过程中每次闪现的名字也应该从remaining里取。这样在停到目标之前的每一次闪现都是当前仍有可能中奖的人逻辑上更完整。有人会问这么弹几下不会导致中奖名单出现“非随机”的感觉吗其实不会因为目标是点击停止的那一刻用random.choice从剩余名单里决定的减速过程只是增加了视觉上的渐进感本质上没有做任何手脚。这一点想清楚代码就好写多了。3. 从零到一完整代码拆解与打包部署3.1 读取名单兼容 Windows 记事本的编码问题读 txt 名单这个看似简单的步骤其实藏着一个巨大的坑。Windows 上很多人用记事本新建文本文件后直接输入名字默认保存的编码是 GBK不是你常见的 UTF-8。如果用open(path, encodingutf-8)去读这个文件某些生僻字或中文符号会直接解码失败轻则乱码重则程序报错崩溃。稳妥的做法是依次尝试多种编码直到成功为止def load_names(path): names [] for enc in (utf-8-sig, gbk, utf-8): try: with open(path, r, encodingenc) as f: text f.read() break except UnicodeDecodeError: continue else: return names for line in text.splitlines(): name line.strip() if name and name not in names: names.append(name) return names注意编码顺序里我把utf-8-sig排在了最前面。这是因为有些文本文件带了 BOM 头用普通utf-8读的话会把 BOM 字符一起读进来导致第一个名字前面多出一个看不见的\ufeff界面显示时第一个名字前面会莫名多出一个小点。utf-8-sig正是专门用来兼容这种情况的。如果失败再降级到gbk最后才尝试严格模式utf-8。还有一个容易被忽略的点读取文本时最好按行分割后用strip()去掉首尾空白。因为名单文件里很容易出现行尾空格、全角空格、空行直接作为名字抽选的话要么显示时名字后面多一个空格要么一个空字符串被当成一个人放进名单里。我在实际使用中会顺手把\u3000这种全角空格也替换掉这些小细节往往决定活动现场的观感。3.2 界面布局与字体方案大字、深底色、左右分区抽奖器界面向来不需要太花哨核心诉求是全场最后一排的人也能看清名字。所以我选择深色背景配高亮黄色字字号开到 60 磅起步按钮和名单记录区左右分列。左侧抽奖区主要包含三部分顶部一行状态文字告诉主持人当前是“点击开始”还是“正在滚动中”中间一个巨大的名字标签底部一个“开始/停止”切换按钮。右侧则固定宽度放一个 Listbox 中奖名单记录区每抽中一个人就往列表里追加一行。这样主持人和观众都能实时看到有哪些人已经中奖避免重复抽取引起的争议。字体选择方面直接写死“微软雅黑”在 Windows 上没问题但换到 macOS 或者 Linux 就找不到这个字体名Tkinter 会默默回到默认字体显示效果难看。更健壮的方式是启动时探测系统可用字体按优先级选择一个from tkinter import font as tkfont def pick_font(family_size): available set(tkfont.families()) for name in (微软雅黑, 苹方, Noto Sans CJK SC, 文泉驿微米黑): if name in available: return (name,) family_size return (TkDefaultFont,) family_size这个函数优先找微软雅黑找不到就找苹方再找不到就找 Noto 系的开源中文字体。几乎所有主流系统都能命中其中一个从而保证名字显示不乱码、不吃豆腐块。3.3 完整可运行的代码不依赖任何第三方库下面是整合了以上思路的完整版本。代码里包含了名单加载、去重、滚动、减速命中、历史记录、全屏切换和空格键控制全部加起来不到 200 行读一遍就能完全理解每句话在做什么。import tkinter as tk from tkinter import filedialog, messagebox, font as tkfont import random import os PALETTE { bg: #0d1b2a, card: #1b263b, txt: #e0e1dd, hot: #ffd166, ok: #7ecb20, } class LotteryApp: def __init__(self, root, names): self.root root self.names names self.remaining names[:] self.history [] self.rolling False self.trace_id None self.is_fullscreen False root.title(滚动抽奖器) root.minsize(900, 560) root.configure(bgPALETTE[bg]) self._build_ui() self._bind_keys() def _build_ui(self): main tk.Frame(self.root, bgPALETTE[bg]) main.pack(filltk.BOTH, expandTrue, padx40, pady30) left tk.Frame(main, bgPALETTE[bg]) left.pack(sidetk.LEFT, expandTrue, filltk.BOTH) self.state_label tk.Label( left, text点击下方按钮或按空格键开始, bgPALETTE[bg], fg#7a8b99, font(, 14) ) self.state_label.pack(pady(0, 8)) self.name_label tk.Label( left, textself.names[0], bgPALETTE[bg], fgPALETTE[hot], fontself._big_font(60, bold) ) self.name_label.pack(expandTrue) self.btn tk.Button( left, text开始抽奖, commandself.toggle, bg#264653, fgwhite, font(, 16, bold), relieftk.FLAT, padx24, pady10, cursorhand2 ) self.btn.pack(pady20) right tk.Frame(main, bgPALETTE[card], width280) right.pack(sidetk.RIGHT, filltk.Y, padx(30, 0)) right.pack_propagate(False) tk.Label( right, text中奖名单, bgPALETTE[card], fgPALETTE[txt], font(, 15, bold) ).pack(pady(16, 6)) self.listbox tk.Listbox( right, bgPALETTE[card], fgPALETTE[hot], font(, 13), relieftk.FLAT, selectbackground#264653 ) self.listbox.pack(filltk.BOTH, expandTrue, padx18, pady(0, 16)) def _big_font(self, size, weight): available set(tkfont.families()) for name in (微软雅黑, 苹方, Noto Sans CJK SC, 文泉驿微米黑): if name in available: return (name, size, weight) return ((), size, weight) def _bind_keys(self): self.root.bind(space, lambda _e: self.toggle()) self.root.bind(F11, lambda _e: self.toggle_fullscreen()) self.root.bind(Escape, lambda _e: self.exit_fullscreen()) def toggle_fullscreen(self): self.is_fullscreen not self.is_fullscreen self.root.attributes(-fullscreen, self.is_fullscreen) def exit_fullscreen(self): self.is_fullscreen False self.root.attributes(-fullscreen, False) def toggle(self): if self.rolling: self.stop_roll() else: if not self.remaining: messagebox.showinfo(提示, 剩余名单为空重新加载名单后可继续) return self.start_roll() def start_roll(self): self.rolling True if self.trace_id is not None: self.root.after_cancel(self.trace_id) self.trace_id None self.state_label.config(text正在滚动中再点一次按钮停止) self.btn.config(text停止) self.name_label.config(fgPALETTE[hot]) self._roll_step() def _roll_step(self): if not self.rolling: return text random.choice(self.remaining) self.name_label.config(texttext) self.trace_id self.root.after(80, self._roll_step) def stop_roll(self): self.rolling False if self.trace_id is not None: self.root.after_cancel(self.trace_id) self.trace_id None self.target random.choice(self.remaining) self.settle_steps 5 self._settle_step() def _settle_step(self): if self.settle_steps 0: self.settle_steps - 1 text random.choice(self.remaining) self.name_label.config(texttext, fgPALETTE[hot]) self.trace_id self.root.after(120, self._settle_step) else: self._mark_result(self.target) def _mark_result(self, name): self.name_label.config(textname, fgPALETTE[ok]) self.remaining.remove(name) self.history.append(name) self.listbox.insert(tk.END, f{len(self.history)}. {name}) self.state_label.config( textf本轮中奖{name}剩余 {len(self.remaining)} 人可抽 ) self.btn.config(text开始抽奖) def load_names(path): text None for enc in (utf-8-sig, gbk, utf-8): try: with open(path, r, encodingenc) as f: text f.read() break except UnicodeDecodeError: continue if text is None: return [] names [] for line in text.splitlines(): name line.strip().replace(\u3000, ) if name and name not in names: names.append(name) return names def main(): root tk.Tk() names [] path names.txt if os.path.exists(path): names load_names(path) if not names: path filedialog.askopenfilename( title请选择名单文件, filetypes[(文本文件, *.txt)] ) if path: names load_names(path) if not names: messagebox.showwarning(提示, 未读取到名单请准备一个每行一个名字的 txt 文件) return LotteryApp(root, names) root.mainloop() if __name__ __main__: main()代码里值得特别说明的是_settle_step的实现。点击停止后target已经确定但并不是立刻亮出来而是先随机闪现 5 次每次间隔 120 毫秒最后才停到目标上。这样下来观众视觉上会经历一次小加速再停住的节奏现场反馈比直接硬停好很多。after_cancel的使用也需要注意每次开启新一轮滚动之前都应该检查trace_id是不是还在如果是就要先取消上一次的定时器否则可能同时存在两个滚动循环在改写界面文字。这个问题我在连续快速点击按钮时遇到过现象就是文字跳动频率变成原来的两倍看起来像开了加速器。3.4 打包成单文件在没装 Python 的电脑上也能跑代码写好后如果要拿到活动现场的电脑上用可能那台机器根本没装 Python。PyInstaller 是官方文档里最常用的打包工具命令也简单pip install pyinstaller pyinstaller --onefile --windowed lottery.py--onefile会把整个运行环境打包成一个单独的 exe--windowed表示启动时不弹出黑色控制台窗口。打包完成后生成的 exe 文件和 names.txt 放在同一个目录下双击即可运行。我在多台不同版本的 Windows 上试过都能正常启动体积大概 8MB 左右比起 Electron 那类动辄上百 MB 的方案轻量得多。如果列表名单需要动态更换建议不要把它打进 exe 里而是放在 exe 同目录下。这样可以随时用记事本改名字不需要重新打包。如果名单路径是纯相对路径“names.txt”程序启动时会以当前工作目录为基准来找文件。有次我直接在文件管理器里双击 exe工作目录恰好就是 exe 所在目录没出问题。但如果通过快捷方式启动工作目录可能指向别处稳妥的做法是用os.path.dirname(os.path.abspath(__file__))拼接路径或者直接像上方完整代码那样用文件选择窗口兜底。4. 活动现场的坑这些比代码本身更值得记下来4.1 空格键没反应焦点到底在哪里写代码时给窗口绑定了空格键切换摆动软件测试时直接用鼠标点按钮空格键倒是反应正常。结果到了活动现场主持人拿起键盘一按空格窗口纹丝不动只能尴尬地拿起鼠标去点按钮。排查后发现原因很科学Tkinter 的bind默认绑定的是窗口自身但活动电脑上可能有其他程序抢了焦点比如输入法状态栏、演示工具的小窗、甚至杀毒软件的悬浮窗按键事件根本不会派发给抽奖器窗口。解决思路有两条。第一种是把绑定的目标从root改成root.bind_all全局捕获空格键事件第二种是从源头避免依赖外接设备干脆给按钮设置focus_set()让窗口一启动就强行占据焦点。但bind_all有个副作用主持人如果在别的窗口打字突然按空格就可能误触发抽奖这个要视现场情况取舍。我当时实际采用的办法是把 F11 和 Escape 也绑定上并在一开始就调用了root.focus_force()。在 Windows 上这个方法是有效的窗口启动后会自动抢到键盘焦点空格键就恢复了。Linux 在某些窗口管理器下focus_force可能无效那就直接用bind_all兜底反正 Linux 会场一般不会有太多杂七杂八的弹窗抢焦点。4.2 全屏模式下的误触退出抽奖界面用的是深色背景配大字号投影出来很专业。但活动场地使用的投影仪信号送达后主持人很容易下意识按 Esc 想退出什么东西结果直接把全屏抽奖器退回了带边框的小窗口全场观众看着桌面壁纸发呆。这个问题的本质是全屏模式和窗口模式之间缺少一层缓冲。我给代码加了一个处理Escape 默认不直接退出全屏而是先弹出一个确认窗口只有再次点击“退出”才生效。这样即使主持人误按 Esc也必须在界面上点一下确认不会瞬间切换破坏现场气氛。还有个隐藏技巧双击名字大标签也可以退出全屏这样遇到紧急情况时可以快速恢复窗口模式去处理别的问题又不会因为误触而中断抽奖。另外一点如果活动用的是一台触控一体机要注意 Tkinter 的按钮在触屏上的点击反馈较弱最好在按钮旁边放一个明显的“按空格键”提示语提醒主持人优先用键盘操作。4.3 字体缺失为什么有人看到的是方块乱码跨平台的字体问题我在第 3.2 节做过处理但这里还是要强调一下Tkinter 的字体缺失不会报错而是静默回退到默认字体。如果默认字体不支持中文字符集界面上就会出现一排方框。最常见的场景是把代码从 Windows 拷贝到一台只装最小化 Linux 系统的电脑上系统没装中文字体包深色背景下全是大大小小的空心方块中奖名单自然没法看。动手之前先花一分钟在目标机器上跑一遍字体探测函数看返回的是哪个字体名。如果返回的是TkDefaultFont那基本可以判断系统中文字体缺失需要现场安装。另一个思路是直接把字体文件放在程序目录里然后用tkfont.Font加载本地字体文件这样不依赖操作系统字体库。这个方法在 macOS 上兼容性一般但 Windows 和 Linux 上都没问题值得一试。4.4 名单顺序影响观感抽奖要的是公平感有人把名单 txt 里人员的顺序写得很有规律比如按报名时间从早到晚排列。随机抽选虽然概率上绝对均匀但观众如果在几十秒滚动里看到字面顺序似乎有规律就会产生“是不是提前排好的”的质疑。应对方案很简单在程序启动时先把名单random.shuffle打乱一次。反正滚动过程中显示的名字是随机挑的完全看不出原始顺序。更重要的是让主持人知道每次中奖的名字都实时从剩余名单中移除并记录方便活动后核对。活动结束后的名单核对也是个容易忽略的需求。抽完奖后中奖名单只存在 Listbox 里关掉电脑就没了。所以我在实际部署时又加了一行导出按钮把history列表写成 csv 文件。这个功能虽然简单但在不少场景下能把“抽完了无从查证”的尴尬直接化解。5. 进阶玩法把基础抽奖器扩展成多场景工具5.1 支持一次抽多个名额年会抽三等奖的时候通常要一次抽取 10 人而不是抽完一个人再点一次开始。基础的滚动抽奖器默认一抽一停直接连抽 10 次要操作 10 次按钮节奏拖沓。解决办法是在界面加一个 Spinbox让操作者预先填好“本轮抽取人数”然后每抽一人自动中停一次中停后点击继续循环 N 次后再结束。实现时可以把人数存在self.batch_size里每次_mark_result执行完后判断一轮批量抽取是否已完成没有完成就自动进入下一次减速命中。要注意的是批量和单次的模式切换尽量保持状态清晰按钮文字“开始抽奖”“停止”来回切换时不要让用户误以为只抽一个人。我在界面右侧状态栏会额外显示“本批还有 3 人待抽”现场操作基本不会混乱。5.2 给抽奖加上声音轻量方案和重量方案音效是现场氛围的重要组成部分。最简单的做法是用winsound模块它是 Windows 自带的播放一小段 wav 不需要任何额外安装import winsound winsound.PlaySound(ding.wav, winsound.SND_ASYNC)这个模块只在 Windows 上可用程序放到 macOS 或 Linux 就会报导入错误。所以需要先做个平台判断或者干脆用os.system(start)这类系统命令去播放音频。更统一的方案是用pygame.mixer效果确实更好支持 mp3、支持多个音效叠加但代价是引入了第三方的 pygame 依赖与最开始“零依赖”的理念相悖。我的建议是如果活动电脑确定是 Windows直接用winsound已经足够否则就保持无声状态现场麦克风配合也很自然。5.3 跳出抽奖名单的限制接入外部名单或数据库基础版读取本地 txt 文件这个对大多数场景够用。但如果你是给某个课程做课堂测练可能名单其实存在班级管理系统里每次手动导出再整理成一行一行的 txt 确实浪费时间。这时候可以把load_names的返回值从文件读取改成 API 请求或者直接读 Excel。读 Excel 稍微有点绕因为标准库没有现成的 Excel 解析器。两个思路一是把 Excel 另存为 csv用csv模块读取二是安装openpyxl直接处理 xlsx 文件。我的建议是优先用 csv因为不需要额外装库而且学校、公司提供的名单导出成 csv 都很容易编码选择时注意用utf-8-sig或根据乱码情况切换到 GBK。这样抽奖器就从一个固定名单工具变成可以反复配合不同数据源的通用工具实用性直接翻倍。5.4 遥控器控制给抽奖器配一个“副手”活动主持人站在台上不方便总是弯腰够键盘和鼠标。这时可以考虑用手上的无线演示笔或遥控翻页器来控制抽奖器。这些设备大多数发送的是键盘事件窗体会收到对应的按键消息。只要把F5、PageDown这类遥控笔能发出的信号绑定到toggle方法上就能实现远程控制。我自己实测过十块钱左右的翻页笔默认发送的是 PageUp/PageDown 两个按键直接绑定Prior和Next为“开始/停止”即可。核心思路就是找到遥控笔实际发送的虚拟键值换到一个稳定的键值绑定。这个技巧解决了活动过程中最烦人的站位问题而且实现成本不过两三行bind代码。抽奖器看起来是个小项目但真正把它打磨到能在活动现场顺利跑完涉及的细节一点也不少。我自己在多次实操中最大的体会是代码逻辑并不是难点难的是把各种边界情况想到——名单编码、字体缺失、焦点抢占、误触退出、批量抽取、结果导出每一项都来自真实场景。这个项目也特别适合当作 Tkinter 的练手项目麻雀虽小五脏俱全。你完全可以把上面的基础版跑通再根据自己的活动类型往里加功能改到最后你会发现它已经变成只属于你的趁手工具。