2026-05-14
Python
0

目录

🗂️ 先看问题出在哪儿
📐 推荐的目录结构
🚪 入口文件:main.py 越薄越好
🏗️ app.py:根窗口的生命周期管理
⚙️ 配置模块:别让 config.json 裸奔
🖼️ 主窗口:框架搭好,页面往里插
📄 页面组件:保持克制
⚠️ 几个常见的结构陷阱
💡 三句话收尾

有一类项目,我见过太多次了。

开始的时候就一个文件——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()

image.png

image.png

这里有个细节值得注意: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 一样。


💡 三句话收尾

  1. 入口文件越薄越好——main.py 只负责启动,不承载任何业务或 UI 逻辑。
  2. 配置要有人管——用一个专门的类封装读写和默认值,别让 JSON 文件裸奔在各处。
  3. 页面只管显示——业务逻辑下沉到 core/ 层,页面和业务之间的边界一旦守住,后续扩展就轻松很多。

这套结构我在几个实际项目里用下来,感觉最大的收益不是"代码更好看了",而是新功能的添加成本明显降低了——加一个新页面,不需要动主窗口;改一个业务规则,不需要翻 UI 代码。这种感觉,写过乱代码的人才懂。

完整的项目模板已在 GitHub 开源,供参考使用。如果你在 CustomTkinter 项目结构上有不同的实践思路,欢迎在评论区交流。


#Python #CustomTkinter #GUI开发 #项目结构 #Windows应用开发

相关信息

我用夸克网盘给你分享了「projectStructDemo.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。 /79fd3YZP7W:/ 链接:https://pan.quark.cn/s/54a1274d963c 提取码:UNpe

本文作者:技术老小子

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!