有一类项目,我见过太多次了。
开始的时候就一个文件——main.py,几十行,跑得挺好。然后需求加了,控件多了,逻辑复杂了,文件越来越长。等到某天打开这个文件,发现它已经悄悄长到八百行,UI 代码、配置读取、业务处理、主题切换全搅在一起,像一锅煮过头的杂烩粥。改一个按钮颜色,得先找半天在哪儿。
这不是 CustomTkinter 的问题。是结构的问题。
CustomTkinter 本身挺好用——比原生 Tkinter 好看多了,主题切换、圆角控件、深色模式,这些在 Windows 下做桌面应用很实用。但它没有告诉你怎么组织代码。官方示例大多是单文件脚本,适合演示,不适合交付。
今天就来聊聊,怎么把一个 CustomTkinter 项目,从"能跑的脚本"变成"可以交给别人维护的应用"。
单文件项目的崩溃,往往不是一夜之间发生的。它是渐进式的——每次加功能,图省事往里塞,久而久之就成了这样:
python# main.py(反面教材,勿模仿)
import customtkinter as ctk
import json, os, threading
root = ctk.CTk()
root.title("我的应用")
config = json.load(open("config.json")) # 配置读取混在最顶上
theme = config.get("theme", "dark")
ctk.set_appearance_mode(theme)
# 然后是一大堆控件定义...
# 然后是回调函数,里面直接写业务逻辑...
# 然后是数据库操作...
# 然后是文件读写...
# 全在一起
这种结构的问题,不是"丑",而是牵一发动全身。你改配置读取方式,可能波及十几个用到 config 变量的地方。你想给某个功能写单元测试,发现根本没法单独 import,因为一 import 就会触发 root = ctk.CTk(),直接弹出窗口。
先把骨架立起来。一个中等规模的 CustomTkinter 项目,目录大概长这样:
my_app/ ├── main.py # 唯一入口,只做启动 ├── app.py # 根窗口和应用生命周期 ├── config/ │ ├── __init__.py │ └── settings.py # 配置读写 ├── ui/ │ ├── __init__.py │ ├── main_window.py # 主窗口框架 │ ├── sidebar.py # 侧边栏组件 │ └── pages/ │ ├── __init__.py │ ├── home_page.py │ └── settings_page.py ├── core/ │ ├── __init__.py │ └── data_service.py # 业务逻辑,不碰 UI ├── assets/ │ ├── icons/ │ └── fonts/ └── requirements.txt
目录不用死记,理解背后的分层逻辑就够了:入口层、界面层、业务层、配置层,四者各司其职,互不越界。
main.py 越薄越好很多人把 main.py 当成"什么都往里放"的地方。实际上,一个规范的入口文件应该薄到几乎透明——它只做一件事:启动应用。
python# main.py
import sys
from app import Application
def main():
app = Application()
app.run()
if __name__ == "__main__":
# 这个判断很重要,防止被 import 时意外执行
sys.exit(main())
就这些。没有控件,没有配置,没有业务逻辑。if __name__ == "__main__" 这个守门员必须在,否则你写测试的时候 import 这个文件,应用就直接启动了——这种坑我踩过,挺烦的。
app.py:根窗口的生命周期管理Application 类负责整个应用的初始化序列——先加载配置,再设置主题,最后才创建窗口。顺序很重要,CustomTkinter 要求在创建任何控件之前就设置好外观模式,否则主题不生效。
python# app.py
# app.py
import customtkinter as ctk
from config.settings import AppSettings
from ui.main_window import MainWindow
class Application:
"""应用程序生命周期管理器"""
def __init__(self):
self._settings = AppSettings()
self._setup_appearance()
self._window: MainWindow | None = None
def _setup_appearance(self):
"""必须在创建任何 CTk 控件之前调用"""
ctk.set_appearance_mode(self._settings.theme)
ctk.set_default_color_theme(self._settings.color_theme)
def run(self):
"""创建主窗口并启动事件循环"""
self._window = MainWindow(settings=self._settings)
self._window.mainloop()
def quit(self):
"""优雅退出,保存配置"""
if self._window:
self._settings.save()
self._window.destroy()
def main():
app = Application()
app.run()
if __name__ == "__main__":
main()


这里有个细节值得注意:quit() 方法在销毁窗口之前先保存配置。如果你直接让用户点右上角的 X 关闭,这个保存动作就丢了——所以后面在主窗口里要绑定 protocol("WM_DELETE_WINDOW", app.quit)。
config.json 裸奔直接用 json.load(open(...)) 读配置,是早期项目里最常见的写法。问题在于:文件不存在怎么办?字段缺失怎么办?类型不对怎么办?这些边界情况,散落在各处的裸读代码是处理不了的。
把配置封装成一个类,统一处理这些情况:
python# config/settings.py
import json
import os
from dataclasses import dataclass, field, asdict
CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".my_app", "config.json")
@dataclass
class AppSettings:
theme: str = "dark"
color_theme: str = "blue"
window_width: int = 1100
window_height: int = 660
last_page: str = "home"
def __post_init__(self):
"""初始化后立即从磁盘加载,用磁盘值覆盖默认值"""
self._load()
def _load(self):
if not os.path.exists(CONFIG_PATH):
return # 第一次运行,用默认值即可
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
# 只更新已知字段,忽略未知字段,防止旧配置污染
for key, val in data.items():
if hasattr(self, key):
setattr(self, key, val)
except (json.JSONDecodeError, OSError):
pass # 配置损坏就用默认值,别崩溃
def save(self):
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(asdict(self), f, indent=2, ensure_ascii=False)
用 dataclass 的好处是字段一目了然,默认值集中管理。配置存到用户目录(~/.my_app/)而不是程序目录,这是 Windows 桌面应用的标准做法——程序目录在安装后可能没有写权限。
主窗口负责整体布局——侧边栏 + 内容区,这是 CustomTkinter 官方示例里最常见的结构。关键在于页面切换的实现方式,很多人直接用 pack_forget() / pack() 来切换,代码写多了很乱。更清晰的做法是维护一个页面字典,按需显示:
python# ui/main_window.py
import customtkinter as ctk
from config.settings import AppSettings
from ui.sidebar import Sidebar
from ui.pages.home_page import HomePage
from ui.pages.settings_page import SettingsPage
class MainWindow(ctk.CTk):
"""主窗口,负责布局和页面路由"""
def __init__(self, settings: AppSettings):
super().__init__()
self._settings = settings
self._pages: dict[str, ctk.CTkFrame] = {}
self._current_page: str = ""
self._configure_window()
self._build_layout()
self._register_pages()
self._navigate(settings.last_page)
# 绑定关闭事件,确保配置被保存
self.protocol("WM_DELETE_WINDOW", self._on_close)
def _configure_window(self):
self.title("My Application")
self.geometry(
f"{self._settings.window_width}x{self._settings.window_height}"
)
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
def _build_layout(self):
# 侧边栏固定在左侧
self._sidebar = Sidebar(self, navigate_callback=self._navigate)
self._sidebar.grid(row=0, column=0, sticky="nsew")
# 内容区占满剩余空间
self._content_frame = ctk.CTkFrame(self, corner_radius=0)
self._content_frame.grid(row=0, column=1, sticky="nsew", padx=10, pady=10)
self._content_frame.grid_rowconfigure(0, weight=1)
self._content_frame.grid_columnconfigure(0, weight=1)
def _register_pages(self):
"""注册所有页面——添加新页面只需在这里加一行"""
pages_map = {
"home": HomePage,
"settings": SettingsPage,
}
for name, PageClass in pages_map.items():
page = PageClass(self._content_frame, settings=self._settings)
page.grid(row=0, column=0, sticky="nsew")
self._pages[name] = page
def _navigate(self, page_name: str):
"""切换页面"""
if page_name not in self._pages:
return
if self._current_page:
self._pages[self._current_page].grid_remove()
self._pages[page_name].grid()
self._current_page = page_name
self._settings.last_page = page_name # 记住最后访问的页面
def _on_close(self):
self._settings.save()
self.destroy()
_register_pages() 这个设计挺实用——以后加新页面,只需要在字典里加一行,主窗口本身不需要改任何逻辑。这就是所谓的"对扩展开放,对修改关闭",放在这里一点不违和。
每个页面是一个独立的 CTkFrame 子类。页面只管自己的 UI,业务数据通过 core/ 层获取,不要在页面里直接写文件读写或网络请求。
python# ui/pages/home_page.py
import customtkinter as ctk
from config.settings import AppSettings
from core.data_service import DataService
class HomePage(ctk.CTkFrame):
"""首页,展示数据概览"""
def __init__(self, parent, settings: AppSettings):
super().__init__(parent, corner_radius=8)
self._settings = settings
self._service = DataService() # 业务层,不是 UI 层
self._build_ui()
self._load_data()
def _build_ui(self):
self.grid_columnconfigure(0, weight=1)
self._lbl_title = ctk.CTkLabel(
self, text="概览", font=ctk.CTkFont(size=20, weight="bold")
)
self._lbl_title.grid(row=0, column=0, padx=20, pady=(20, 10), sticky="w")
self._lbl_count = ctk.CTkLabel(self, text="加载中...")
self._lbl_count.grid(row=1, column=0, padx=20, pady=5, sticky="w")
def _load_data(self):
"""从业务层拉数据,更新 UI"""
count = self._service.get_item_count()
self._lbl_count.configure(text=f"共 {count} 条记录")
页面代码干净,逻辑清晰。DataService 里的具体实现——不管是读数据库还是读文件——页面完全不关心。
陷阱一:在模块级别创建 CTk 控件。 比如在某个工具模块的顶层写 icon = ctk.CTkImage(...),这行代码在 import 时就执行了,如果此时 Tk 根窗口还没创建,直接报错。所有控件的创建,必须在 CTk() 实例化之后。
陷阱二:循环 import。 ui/ 层 import core/ 层是正常的,反过来就是循环依赖。业务层绝对不能 import UI 层——哪怕是"只用一个常量",也不行。一旦发现需要这么做,通常意味着那个常量应该放到 config/ 层。
陷阱三:直接传 root 到处跑。 有些代码把根窗口对象当全局变量,在各个模块里到处 import root。这会让模块之间产生隐式耦合,改起来很痛苦。正确做法是通过构造函数参数传递依赖,就像上面代码里传 settings 一样。
main.py 只负责启动,不承载任何业务或 UI 逻辑。core/ 层,页面和业务之间的边界一旦守住,后续扩展就轻松很多。这套结构我在几个实际项目里用下来,感觉最大的收益不是"代码更好看了",而是新功能的添加成本明显降低了——加一个新页面,不需要动主窗口;改一个业务规则,不需要翻 UI 代码。这种感觉,写过乱代码的人才懂。
完整的项目模板已在 GitHub 开源,供参考使用。如果你在 CustomTkinter 项目结构上有不同的实践思路,欢迎在评论区交流。
#Python #CustomTkinter #GUI开发 #项目结构 #Windows应用开发
相关信息
我用夸克网盘给你分享了「projectStructDemo.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。
/79fd3YZP7W:/
链接:https://pan.quark.cn/s/54a1274d963c
提取码:UNpe
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!