ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

NSIS+Duilib:打造现代自定义安装包界面的完整方案

NSIS+Duilib:打造现代自定义安装包界面的完整方案

简介:这是一份面向Windows桌面开发者的资源包,目标是让开发者将NSIS安装逻辑与Duilib界面框架结合,制作出界面美观、交互友好的自定义安装程序。适合对NSIS脚本不熟悉,又想摆脱默认安装向导样式的中高级开发人员。压缩包共451个文件,约9.6MB,其中包含大量png皮肤素材、nsh脚本片段、nlf语言文件、xml布局定义及dll运行时组件,也收录了nsi主脚本、bat构建工具、chm帮助文档和示例exe,结构便于按模块复用。包内资源覆盖了自定义皮肤、安装界面布局、安装步骤控制、注册表/快捷方式操作以及日志输出等常用场景,开发者可在此基础上快速搭建出符合产品风格的安装流程。压缩包已有2943人学习下载,口碑较好。对于希望提升安装程序视觉质量与用户体验的开发者而言,这是一份实用且可直接演化的参考工程。

1. 把 NSIS 引擎交给 Duilib 界面:这场改造到底在改什么

很多做客户端交付的同学都有过同一种尴尬:程序本体做得再精致,一到安装环节就露怯。默认的 NSIS 向导页、生硬的字体、老旧的按钮,用户第一眼就觉得“这软件怕不是十年前的东西”。大家常说换张 Banner 图就好,真做过的都知道,那只是把绿皮火车刷了个漆,车厢结构一点没动。真正拿得出手的定制安装程序,是把整个 UI 层从 NSIS 的默认控件里摘出来,换上一套现代自绘界面,同时把文件释放、注册表写入、快捷方式创建这些安装逻辑继续交给 NSIS 引擎去跑。这就是 NSIS+Duilib 组合的核心价值:NSIS 负责“装得稳”,Duilib 负责“长得体面”。

这个方向适合两类人:一类是产品团队想把安装包外观做成和主程序一脉相承的视觉风格,另一类是开发团队需要在安装过程中塞入复杂交互,比如协议勾选、组件选择、环境检测、账号录入。这两类需求用 NSIS 原生的 InstallOptions 都能做,但做到精致程度的上限很低。把 Duilib 引入之后,界面这一侧几乎不受限制,剩下的事情就是解决两个框架的通信和生命周期问题。

2. 先把两种技术框清楚:NSIS 的边界和 Duilib 的 UI 能力

2.1 NSIS 脚本引擎到底能改到什么程度

NSIS 最强的部分从来不是界面,而是那套成熟的脚本机制。它能把文件压缩进安装包、在安装时解压到指定目录、写注册表、建快捷方式、注册卸载器,还能根据运行时条件分支执行。这些能力都围绕脚本指令展开,脚本里声明的 Section、Function、宏,构成了整个安装过程的执行骨架。

但是 NSIS 自带的界面层非常有限。标准的 MUI2(Modern UI 2)页面本质上是一组系统控件拼出来的向导页,预选安装目录的目录选择框、组件多选框、安装进度条,能用但不好看。想自己加一个自定义页面,官方提供的方法是写 INI 文件配合 InstallOptions 插件,页面上放什么控件、控件在哪个坐标,全部写在 INI 的字段里。这个方案的维护成本随着界面复杂度直线上升,做三五个页面还能忍,做到品牌化视觉的时候,光是用 INI 描述坐标和状态就够让人崩溃的。

好在 NSIS 留了一个更底层的扩展口:插件 DLL。NSIS 脚本可以调用 DLL 里导出的函数,并把脚本变量传入 DLL 环境。反过来,DLL 里的代码也能引导 NSIS 执行特定脚本函数。这个机制就是 Duilib 能进来的门缝。常见的做法是让一个自定义页面挂钩到一个插件函数,插件函数内部创建出 Duilib 窗口,让这个窗口覆盖或替换掉 NSIS 默认页面区域。

2.2 Duilib 作为安装界面层,能做哪些事

Duilib 是一套 Windows 平台上的 DirectUI 界面库,所谓 DirectUI 是指所有控件由框架自己绘制,而不是依赖系统公共控件。这就带来一个很关键的好处:控件长相完全由代码和资源文件决定,不受系统版本和主题影响。按钮、编辑框、进度条、单选、复选、列表,全部可以用 XML 描述布局,用代码控制行为,视觉风格可以做得很统一。

拿安装程序这个场景来说,Duilib 现成的控件基本全部够用。安装路径输入用 CEditUI,组件勾选用 CCheckBoxUI,安装进度用 CProgressUI,欢迎页的图文混排可以用 CLabelUI 配合 CContainerUI 自由排版。再加上它支持半透明背景、圆角矩形、自绘状态图,做出的效果比传统向导页高出一个量级。与其去学 MUI2 那套模板,不如直接用 Duilib 把整个页面重画一遍。

还有一点容易被忽略:Duilib 对 DPI 缩放有自己的一套资源缩放逻辑。安装包分发给不同分辨率的机器时,界面能按缩放因子自适应,而 NSIS 原生页面在高 DPI 下经常出现字体发虚、控件错位。这一点在笔记本电脑横行的当下格外有存在感。

2.3 两条组合路线的选型判断

把两者接起来,业内实际操作中有两条路线。一条是以 NSIS 为主体,NSIS 启动后加载一个插件 DLL,DLL 内部创建 Duilib 窗口,窗口和脚本之间通过插件接口通信。另一条是以 Duilib 为主体,先启动一个小型主程序,界面交互完成后再去调用 NSIS 的静默安装包,通过配置文件或者进程参数把用户选择的内容传过去。

这两条路各有适用场景。前者适合安装流程复杂、需要步骤之间紧密衔接的项目,所有逻辑保持在 NSIS 的掌控内,界面的存在只是换了一层皮。后者的好处是界面和安装引擎完全解耦,升级安装逻辑时不用重新编译界面程序,反过来也一样,但对安装流程的控制力弱一些,多了一步进程间通信。

对比维度NSIS 为主体,插件承载 DuilibDuilib 为主体,外挂 NSIS 引擎
安装流程控制强,脚本直接控制每一步弱,UI 只能发起和等待结果
开发语言重心C++ 插件 + NSIS 脚本C++ 界面程序 + NSIS 静默脚本
改动成本首次接入成本高,后续迭代快首次接入简单,流程改动要同步两套
适合场景多步骤向导、交互密集单页品牌化安装、配置极简

我一般建议,只要安装过程超过两个页面,就选第一条路线。第二条路线做出来的东西本质上是“启动器 + 后台解压器”,遇到安装前检测磁盘空间、依赖组件这类需要中途反馈给用户的场景,处理起来非常别扭。下面整篇的代码和参数都以第一条路线为准。

3. 用 NSIS 插件承载 Duilib 窗口:最小可运行工程

3.1 工程结构:一个 DLL 插件,一段 NSIS 脚本

动手之前先把工程骨架搭起来。插件部分是一个 C++ 动态库项目,编译产物是一个 DLL,NSIS 脚本里通过!system或者插件命令直接调用它。Duilib 的源码有现成的库工程,添加到解决方案里一起编译即可。目录结构大致如下:

InstallerUI/ ├── duilib/ // Duilib 源码与资源 ├── plugin/ │ ├── duilib_plugin.cpp // NSIS 插件导出实现 │ ├── install_window.h // 安装主窗口类声明 │ ├── install_window.cpp // 安装主窗口实现 │ └── resource/ │ ├── skin.xml // Duilib 界面布局描述 │ └── theme/ // 图片、字体等资源 ├── nsis/ │ └── installer.nsi // NSIS 脚本 └── output/ └── Installer.exe

NSIS 插件有一个固定的导出函数约定,DLL 至少需要导出一个函数,NSIS 脚本里以duilib_plugin::function_name的格式调用。插件函数的签名是固定的:接收一个HWND(NSIS 传过来的父窗口句柄)、一个整数参数、一个变量栈指针。这个签名在 NSIS 插件开发文档里有明确约定,照着实现就不会出错。

// duilib_plugin.cpp #include <windows.h> #include "nsis/pluginhelper.h" extern "C" void __declspec(dllexport) ShowInstallUI(HWND hwndParent, int string_size, char_t* variables, stack_t** stacktop) { // 插件函数入口:hwndParent 是 NSIS 页面的宿主窗口 // variables 是 NSIS 变量区的指针,插件可通过它读写脚本变量 EXDLL_INIT(); // 这里把控制权转交给 Duilib 窗口类 CInstallWindow win; win.Create(NULL, NULL, UI_WNDSTYLE_FRAME, 0, 0, 0, 0, hwndParent); win.CenterWindow(); win.ShowWindow(); // 进入 Duilib 消息循环,窗口关闭后返回 win.Run(); }

这段代码里要留意两个点。hwndParent必须传给 Duilib 窗口的 Create,这样能保证安装界面始终模态在 NSIS 宿主之上,不会出现任务栏里两个窗口互相抢焦点的问题。win.Run()会阻塞当前线程直到窗口关闭,这是刻意为之,目的是让插件函数不要提前返回,否则 NSIS 会立刻进入下一步脚本,界面会被直接销毁。

3.2 窗口类与消息循环的写法

Duilib 窗口类的写法有一套固定的骨架。继承CWindowWnd和INotifyUI,在OnCreate里初始化CPaintManagerUI,加载 XML 布局资源,然后注册控件通知事件。安装窗口的关闭逻辑要自己处理,不能像默认的 MessageBox 一样点了 X 就直接消失,得先问过 NSIS 那边能不能取消。

// install_window.h class CInstallWindow : public CWindowWnd, public INotifyUI { public: CInstallWindow() {} virtual LPCTSTR GetWindowClassName() const { return _T("InstallWindow"); } virtual void OnFinalMessage(HWND hWnd) { delete this; } virtual void Notify(TNotifyUI& msg); protected: virtual LRESULT HandleMessage(UINT uMsg, WPARAM wParam, LPARAM lParam); virtual LRESULT OnCreate(UINT uMsg, WPARAM wParam, LPARAM lParam, BOOL& bHandled); private: CPaintManagerUI m_pm; // Duilib 核心,管理所有控件和绘制 CProgressUI* m_pProgress; CEditUI* m_pEditPath; };

OnCreate里的逻辑很直接:调用m_pm.Init把窗口和绘制管理器绑定,然后m_pm.AttachDialog加载 XML,m_pm.AddNotifier让窗口接收控件事件。注意 XML 里的控件名字要和代码里FindControl的名字完全一致,否则编译能过、运行时就是找不到控件,空指针崩溃。

// install_window.cpp 关键片段 LRESULT CInstallWindow::OnCreate(UINT uMsg, WPARAM wParam, LPARAM lParam, BOOL& bHandled) { m_pm.Init(m_hWnd); // 加载界面布局,这里用的是相对路径,发布时确保 skin.xml 和 theme 目录与 DLL 同级 if (!m_pm.AttachDialog(_T("resource/skin.xml"))) { ::MessageBox(m_hWnd, _T("加载界面资源失败"), _T("错误"), MB_OK); return -1; } m_pm.AddNotifier(this); // 绑定控件指针,后续读取用户输入和更新进度都通过这些指针 m_pEditPath = static_cast<CEditUI*>(m_pm.FindControl(_T("edit_install_path"))); m_pProgress = static_cast<CProgressUI*>(m_pm.FindControl(_T("progress_install"))); // 初始进度为 0,路径默认为空,由 NSIS 脚本在调用插件前把默认路径传进来 if (m_pProgress) m_pProgress->SetValue(0); return 0; }

资源加载失败是刚上手最常见的报错点。Duilib 的AttachDialog需要能访问到 XML 文件,而插件 DLL 的工作目录往往不是安装包释放后的临时目录。我一般会在调用AttachDialog之前,用SetCurrentDirectory把工作目录切到 DLL 所在路径,再从相对位置加载资源。另一种做法是把 XML 打包进 DLL,用m_pm.LoadResource走内存加载,少一层路径问题但每次改界面都得重编 DLL。

3.3 Duilib 控件与 NSIS 脚本的通信方式

界面和脚本之间要交换四类信息:安装路径、组件勾选状态、按钮点击事件、进度反馈。前两类是界面到脚本方向,第三类是双向的,第四类是脚本到界面方向。

按钮点击事件的处理在Notify里。用户点击“安装”按钮后,Duilib 会触发UI_EVENT_CLICK通知,代码读取控件值,然后调用 NSIS 插件 API 里的ExecuteCodeSegment跳转到脚本里预埋的执行函数。ExecuteCodeSegment是一个回调函数指针,NSIS 在执行插件时把它挂在了一个全局结构里,插件代码里要先保存这个结构,然后才能调用它。

// 保存 NSIS 回调环境(在插件入口处调用 EXDLL_INIT 后) static NSISExports g_nsis; // install_window.cpp Notify 片段 void CInstallWindow::Notify(TNotifyUI& msg) { if (msg.sType == DUI_MSGTYPE_CLICK) { if (msg.pSender == m_pBtnInstall) { // 把界面路径传给 NSIS 变量 InstallPath g_nsis.set_var("InstallPath", m_pEditPath->GetText()); // 跳转到 NSIS 脚本里的 install_start 函数 g_nsis.exec_code_segment("install_start"); } else if (msg.pSender == m_pBtnCancel) { // 先询问 NSIS 是否可以取消,返回 1 表示同意取消 if (g_nsis.register_plugin_callback("check_cancel") == 1) { Close(); } } } }

exec_code_segment的字符串参数对应 NSIS 脚本里的一个Function标签。这段调用是同步的,也就是说 Duilib 的消息循环会被 NSIS 的安装指令阻塞,界面上的进度条必须用别的机制来更新,否则安装过程中界面会像死掉一样。进度更新的处理方式在下一章展开,这里先把通信链路建立起来。

4. 参数配置:从界面取值到安装动作的完整映射

4.1 NSIS 脚本里如何接收 Duilib 传来的值

NSIS 脚本这一侧,先要声明一个自定义页面,把页面回调指向插件函数。Page指令支持pre、show、leave三个阶段的回调函数名。我们通常在show阶段调用插件,让 Duilib 窗口出现在当前页面。

; installer.nsi !include "MUI2.nsh" !include "x64.nsh" ; 声明自定义安装页 Page custom ShowCustomPage ; 预留给插件调用的函数,这是整个安装动作的起点 Function install_start ; 停用安装页面的下一步按钮,防止用户重复点安装 GetDlgItem $0 $HWNDPARENT 1 EnableWindow $0 0 ; 从变量区读取 Duilib 写入的安装路径 StrCpy $INSTDIR $InstallPath ; 执行具体的安装逻辑 Call do_install_files ; 通知 Duilib 界面安装完成 MessageBox MB_OK "安装完成" FunctionEnd Function ShowCustomPage ; 调用插件 DLL 里的 ShowInstallUI 函数 ; 这个调用会阻塞,直到 Duilib 窗口关闭才返回 duilib_plugin::ShowInstallUI FunctionEnd

$InstallPath是一个自定义变量,Duilib 插件写入这个变量的方式有两种。一种是通过set_var接口直接映射到 NSIS 变量区,前提是插件代码里知道变量的索引,这要求在 NSIS 脚本里声明变量时用Var /GLOBAL InstallPath。另一种方式是插件把路径通过栈推回去,NSIS 用Pop指令取出来。两种方式效果一样,变量方式更直观,栈方式更符合插件 API 习惯,我偏好变量方式,因为跨多个函数传递时不用反复压栈出栈。

安装目录的改动是这把架构里最常见的联动场景。用户界面上的“浏览”按钮负责弹目录选择框,选中的路径写回 Duilib 的编辑框;点击安装后,这个路径要同时更新 NSIS 的$INSTDIR,否则安装程序会把文件按脚本默认路径放出去,界面白选了。

4.2 进度条如何从安装进程反推回界面

这块是几乎所有第一次做 NSIS+Duilib 组合的人都要翻车的地方。NSIS 的安装进度是按文件粒度推进的,SetDetailsPrint会把进度文字输出到默认详情页,但 Duilib 窗口是独立绘制的,根本看不到 NSIS 内部的状态。想让进度条动起来,得建立一条数据通路。

最实用的方案是共享文件。NSIS 脚本在安装文件的过程中,把当前进度写进一个临时文件,Duilib 窗口里放一个定时器,每 500 毫秒读取这个文件,解析出百分比,更新进度条。为什么不用共享内存?共享内存需要跨进程句柄同步,在插件模式下,Duilib 窗口和 NSIS 其实运行在同一个进程里,理论上可以直接调函数,但 NSIS 脚本语言里没有办法在文件复制的间隙去执行一段任意代码,所以必须通过一个中间介质来传。

; 安装文件时更新进度 Function do_install_files StrCpy $0 0 DetailPrint "开始释放文件..." ; 这里用 GetTempFileName 生成一个进度文件路径 GetTempFileName $3 StrCpy $4 $3 ; 假设要释放 10 个文件,每释放一个就更新一次进度文件 StrCpy $1 1 loop: SetOutPath "$INSTDIR\dir$1" File "source\file$1.bin" ; 计算进度 = ($1 * 10) 拼成字符串 IntOp $2 $1 * 10 StrCpy $5 "$2" ; 写入进度文件 FileOpen $6 "$4" w FileWrite $6 "$5" FileClose $6 IntOp $1 $1 + 1 IntCmp $1 11 loop done done: ; 写入 100 表示完成 FileOpen $6 "$4" w FileWrite $6 "100" FileClose $6 FunctionEnd

Duilib 窗口里的定时器用SetTimer创建,在HandleMessage里处理WM_TIMER,读取进度文件并更新进度条。需要注意的是这个文件路径要提前约定好。可以在插件入口时用 NSIS 的$PLUGINSDIR生成一个路径,通过变量传进 Duilib,也可以在 Duilib 里直接用GetTempPath生成,安装结束后清理掉。

4.3 三个安装动作前必须调好的参数

一是压缩方式。NSIS 默认的压缩算法是 ZLib,单线程、速度快、体积大。如果不考虑安装包体积,用默认没问题;如果产品要对外分发、需要压缩比,建议选 LZMA,但 LZMA 在安装时解压耗时明显变长。考虑到界面是现代风格的用户感知,安装速度是体验的一部分,我一般推荐 ZLib 配合 Solid 关闭,用体积换流畅度。

二是权限请求。安装程序经常需要写Program Files或者注册表HKLM,必须申请管理员权限。在脚本顶部写RequestExecutionLevel admin,这样 UAC 弹窗出现在安装包启动时。如果只写user,运行时写Program Files会静默失败,用户看到的结果就是“安装完了但程序不见了”,排查起来非常玄学。

三是架构判断。64 位系统上安装路径默认是C:\Program Files (x86)还是 64 位目录,取决于脚本有没有做架构判断。最稳妥的是在脚本开头用${If} ${RunningX64}检测一次,设置不同的默认安装目录,同时把 32 位注册表视图和 64 位视图分开写,避免程序装完注册表写入位置不对,卸载时留下残留。

5. 避坑:界面卡死、进度不动、配置丢失,三条最常翻车的路

5.1 现象:窗口创建成功但点击按钮毫无反应

这个故障通常表现为:Duilib 窗口正常显示,界面能画出来,但点击任何按钮都像打在死区上,窗口甚至无法拖动。

原因是 NSIS 脚本调用插件函数时,当前线程进入了 NSIS 的阻塞状态,而 Duilib 的消息循环可能因为父子窗口关系没有正确拿到键盘和鼠标消息。更常见的原因是插件函数里Create之后立刻被别的事件打断,消息泵还没有跑起来。

解决方法是确认创建窗口时用的父窗口句柄是 NSIS 当前页面句柄而不是一个空句柄,并在创建完成后立即进入CWindowWnd::Run的消息循环。如果窗口仍然不响应,检查 Duilib 的m_pm.Init是否被调用,没有初始化绘制上下文,控件事件根本不会分发。

5.2 现象:安装速度很快但进度条一直停在 0%

安装文件量大时尤其明显,安装包要跑十几秒,Duilib 进度条却完全不动,安装完成后直接跳到 100%。

原因是 NSIS 的File指令一次复制一个文件,在这个复制动作执行完毕之前,脚本不会让出控制权,进度文件也没有更新,界面那边干等。

解决思路是拆小文件粒度,别把一个 500MB 的大文件一次性复制,可以用 NSIS 的File /r配合递归复制,让脚本按目录迭代写入进度。实在拆不开的大文件,可以改在后台线程里跑安装逻辑,用线程更新进度文件,Duilib 定时器照常读取。

5.3 现象:安装结束后设置的配置项丢失

用户明明在界面上勾选了“添加桌面快捷方式”,装完后桌面空空如也。

原因是 NSIS 脚本的变量作用域没有对上。插件写入变量的时机是在Notify事件里,但此时可能还没有进入安装 Section,或者变量被Section内部的局部计算覆盖了。另一个常见原因是快捷键创建逻辑读取的是另一个变量名,两处拼写不一致。

解决方法是统一变量命名,建议在脚本最顶部用Var /GLOBAL声明全部跨页面变量,插件写入名和脚本读取名保持完全一致,写完立刻用MessageBox打印调试,先确认变量传进来了再排查后续逻辑。

5.4 现象:高 DPI 屏幕上控件错位、文字被裁

4K 屏、150% 缩放下,Duilib 窗口的布局从正常比例变成错位重叠。

原因是 Duilib 的资源缩放机制要求在创建窗口前先设置缩放因子,而 NSIS 插件初始化时读取不到系统的 DPI 信息,或者读取到的值和实际值不符。

解决方法是插件函数入口处调用SetProcessDpiAwarenessContext获取真实 DPI,把缩放因子传给 Duilib 的全局参数,确保控件坐标和字体大小按比例缩放。注意要放在创建窗口之前,否则窗口尺寸已经按 96 DPI 建立,中途再改就晚了。

5.5 现象:用户点取消后进程没有完全退出

关闭 Duilib 窗口后,任务管理器里安装进程还在,界面消失但后台一直占着 CPU。

原因是 NSIS 的安装流程还在执行中,插件函数返回后,NSIS 并不知道 UI 已经关闭,继续把脚本剩下的指令跑完。如果脚本里卸载逻辑有阻塞操作,进程就会一直挂住。

解决方式是在 Duilib 关闭事件中先同步调用 NSIS 脚本里的abort_install函数,让脚本主动跳转到退出流程,再关闭窗口。两个动作顺序不能反,先关窗口再通知脚本,脚本里的页面上下文已经没了。

6. 进阶:安装界面与安装引擎的完全解耦打法

如果你的安装流程足够简单,只有一页品牌展示加一个安装按钮,可以试试更省事的架构:Duilib 主程序 + 后台 NSIS 静默包。主程序启动时读一个配置文件,用户点击安装后,把选择写入同一个配置文件,然后调用 NSIS 静默安装包,静默包里的脚本读取配置并按配置执行。这样界面和引擎彻底分离,后续改界面不用重新打包安装逻辑,维护成本下降明显。

// 主程序里调用静默安装包 void OnInstallClicked() { // 写配置文件,NSIS 脚本会读取这里 WritePrivateProfileString(_T("Install"), _T("Path"), m_editPath->GetText(), _T(".\\install_cfg.ini")); WritePrivateProfileString(_T("Install"), _T("Shortcut"), m_bShortcut ? _T("1") : _T("0"), _T(".\\install_cfg.ini")); // 启动 NSIS 静默安装包 // /S 是静默参数,/config= 指向配置文件 SHELLEXECUTEINFO sei = {0}; sei.cbSize = sizeof(SHELLEXECUTEINFO); sei.fMask = SEE_MASK_NOCLOSEPROCESS; sei.lpFile = _T("installer_core.exe"); sei.lpParameters = _T("/S /config=install_cfg.ini"); sei.nShow = SW_HIDE; ShellExecuteEx(&sei); // 等待安装进程结束,再根据返回值决定界面提示 WaitForSingleObject(sei.hProcess, INFINITE); }

静默包里的脚本也简单,用ReadINIStr把配置读回变量,再走常规的 Section 逻辑。这个方案的代价是安装过程中的交互瞬间不是实时的,进度只能靠主程序轮询安装进程是否结束,做不了细粒度反馈。适合安装时间在十秒内的场景,拉长了会显得像卡死。

说到底,选哪条路线取决于你要多少控制力。我做过一个模拟项目X,刚开始图省事选了完全解耦方案,后来加了一个环境检测步骤,要求检测失败时界面给出具体原因并阻止安装,结果只能用文件通信绕了一大圈才实现。最后回退到插件方案,反而半小时就改完了。控制力和开发成本永远是权衡,不是单选题。这也是我后来养成的习惯:先画安装流程状态图,再决定架构。如果你的流程状态超过五个,就别犹豫,直接走插件路线。希望帮到你。

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

RELATED READING

延伸阅读

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