wenmo8 发布的文章

上一篇:《搭建人工智能多模态大语言模型的通用方法》

在今天的文章中,我将回顾2024年有关多模态大语言模型(LLM)的文献资料,主要关注过去几个月发布的工作,以确保范围合理。

因此,这并不是一个关于多模态LLM的历史性概述或全面回顾,而是对最新进展的简要探讨。同时,我会尽量保持总结简洁,不加太多无关内容,因为要介绍10项研究。

文章最后的结论部分会提供一个总结,比较这些论文中使用的方法。

4.1 Llama 3 模型系列

Meta AI 的 Llama 3 模型系列 论文(发表于2024年7月31日)在今年夏天发布,但在LLM领域,感觉像是很久以前的事情了。然而,考虑到他们只是描述了多模态模型,但直到更晚才真正发布模型,我认为将 Llama 3 包括在这份列表中是合理的。(Llama 3.2 模型正式宣布并开放获取的时间是2024年9月25日。)

Llama 3.2 是一种多模态模型,提供了 110亿和900亿参数的版本。这些模型基于之前描述的跨注意力(cross-attention)方法,与下图所示一致。

Llama 3.2 的多模态LLM方法示意图

(摘自 Llama 3 论文的注释图:
https://arxiv.org/abs/2407.21783。这张图为了突出图像部分,视频和语音部分被视觉遮蔽。)

注意,虽然图中显示了视频和语音作为可能的模态,但截至撰写本文时,发布的模型仅支持图像和文本。

Llama 3.2 使用了跨注意力方法,但与我之前提到的有些不同。通常在多模态LLM开发中,我们会冻结图像编码器的参数,只更新语言模型的参数。然而,这里的研究人员几乎采取了相反的做法:他们更新了图像编码器的参数,而语言模型的参数则保持不变。研究人员指出,这样做是有意的,目的是保留其纯文本能力,使得 110亿和900亿参数的多模态模型可以无缝替代 Llama 3.1 的80亿和700亿参数纯文本模型,用于文本任务。

训练流程

训练分多个迭代,从 Llama 3.1 的文本模型开始。在添加图像编码器和投影(即“适配器”)层之后,模型在图文数据上进行预训练。接着,与 Llama 3 纯文本模型训练流程类似(我在之前的文章中写过),模型进行指令和偏好微调。

不同于采用 CLIP 等预训练模型作为图像编码器,研究人员从零开始预训练了一个视觉变换器(ViT)。具体而言,他们采用了经典视觉变换器架构(Dosovitskiy等,2020)中的 ViT-H/14 变体(6.3亿参数)。他们在包含25亿对图文数据的数据集上进行了5个epoch的预训练,这发生在将图像编码器连接到LLM之前。(图像编码器接收 224×224 分辨率的图像,将其分割为14×14的网格,每个网格块大小为16×16像素。)

由于跨注意力层会显著增加参数量,因此每四个变换器块才添加一次跨注意力层。(对于80亿参数的模型,这会增加30亿参数;对于700亿参数的模型,这会增加200亿参数。)

4.2 Molmo 和 PixMo:开源权重与数据的多模态模型前沿探索

《Molmo 和 PixMo:开源权重与数据的多模态模型前沿探索》 论文(2024年9月25日)非常值得注意,因为它不仅承诺开源模型权重,还会开源数据集和源码,类似于纯语言模型 OLMo LLM。(这对LLM研究非常有利,因为研究人员可以查看完整的训练过程和代码,还能在同一个数据集上进行消融实验并复现结果。)

如果你好奇为什么论文标题里有两个名字——Molmo 指代模型(Multimodal Open Language Model,多模态开源语言模型),而 PixMo(Pixels for Molmo)则是对应的数据集。


Molmo 解码器(Decoder-Only)方法示意图(方法A)

注释图改编自《Molmo 和 PixMo:开源权重与数据的多模态模型前沿探索》论文:

https://www.arxiv.org/abs/2409.17146

如上图所示,图像编码器使用的是现成的视觉变换器(Vision Transformer),具体来说是 CLIP 模型。“Connector”(连接器)在这里指代“投影器”,它的作用是将图像特征与语言模型对齐。

Molmo 简化了训练流程,避免了多次预训练阶段,选择了一种更简单的统一训练管道。这个方法更新了所有参数,包括基础LLM、连接器以及图像编码器的参数。

Molmo 团队为基础LLM提供了多种选项:

• OLMo-7B-1024

(完全开源的模型框架)

• OLMoE-1B-7B

(一种专家混合架构,是最有效率的模型)

• Qwen2 7B

(一种开源权重模型,性能优于 OLMo-7B-1024)

• Qwen2 72B

(开源权重模型,也是性能最佳的模型)

4.3 NVLM:开放的前沿级多模态LLM

NVIDIA 的 NVLM:开放的前沿级多模态LLM 论文(2024年9月17日)非常有意思,因为它不仅专注于一种方法,而是同时探索了两种方法:

• 方法A:统一嵌入解码架构(“仅解码器架构”,NVLM-D);

• 方法B:跨模态注意力架构(“基于跨注意力的架构”,NVLM-X)。

此外,他们还开发了一种混合方法(NVLM-H),并对这三种方法进行了公平对比。

三种多模态方法的概述

(注释图摘自《NVLM:开放的前沿级多模态LLM》论文:
https://arxiv.org/abs/2409.11402)

正如下图总结的那样,NVLM-D 对应方法A,而 NVLM-X 对应方法B,具体在前文中已有讨论。混合模型(NVLM-H)的概念是结合两种方法的优点:首先输入一张缩略图,然后通过跨注意力传递一组动态数量的图像块,从而捕捉更高分辨率的细节信息。

研究团队的发现可以总结如下:

  1. NVLM-X 在处理高分辨率图像时展现了卓越的计算效率。

  2. NVLM-D 在OCR(光学字符识别)相关任务中达到了更高的准确率。

  3. NVLM-H 结合了两种方法的优势。

训练流程和方法

和 Molmo 及其他方法类似,NVLM 研究团队不是从零开始预训练一个多模态模型,而是从一个纯文本LLM开始(通常这种方法表现更好)。此外,他们选择使用指令微调后的LLM,而不是基础LLM。具体来说,他们的基础LLM是 Qwen2-72B-Instruct(据我所知,Molmo 使用的是 Qwen2-72B 基础模型)。

在 NVLM-D 方法中,他们训练了所有的LLM参数,而对于 NVLM-X,他们发现冻结原始LLM参数,仅在预训练和指令微调阶段训练跨注意力层效果很好。

图像编码器和投影器

在图像编码器方面,他们没有使用常见的 CLIP 模型,而是选择了 InternViT-6B,并在所有阶段保持参数冻结。

投影器使用的是一个多层感知器(MLP),而不是单一线性层。

4.4 Qwen2-VL:在任何分辨率下增强视觉-语言模型对世界的感知

前面提到的两个论文和模型——Molmo 和 NVLM——都基于 Qwen2-72B LLM。而在这篇论文中,Qwen 研究团队自己发布了一种多模态LLM,Qwen2-VL:在任何分辨率下增强视觉-语言模型对世界的感知(发表于2024年10月3日)。

这项研究的核心是他们所谓的
“Naive Dynamic Resolution”(简单动态分辨率)
机制(“Naive”这个词是有意使用的,并不是“Native”(原生)的拼写错误,尽管“Native”这个词也适合)。这个机制允许模型处理不同分辨率的图像,而不是简单地进行降采样,从而能够输入原始分辨率的图像。

多模态 Qwen 模型的概述

(注释图摘自 Qwen2-VL 论文:
https://arxiv.org/abs/2409.12191)

该模型通过一个经过修改的视觉变换器(ViT)实现原生分辨率输入,这一修改去掉了原有的绝对位置嵌入(absolute position embeddings),并引入了二维旋转位置编码(2D-RoPE)。

他们使用了一个经典的视觉编码器,参数量为 675M,以及不同大小的LLM主干模型,具体参数如下面的表格所示。

不同 Qwen2-VL 模型的组成部分

(注释图摘自 Qwen2-VL 论文:
https://arxiv.org/abs/2409.12191)

训练过程分为三个阶段:

  1. 仅预训练图像编码器;

  2. 解冻所有参数(包括LLM);

  3. 冻结图像编码器,仅对LLM进行指令微调(instruction-finetuning)。

这三阶段的流程结合了高效的视觉处理与强大的语言理解能力,从而使 Qwen2-VL 能够更好地感知和处理来自现实世界的视觉输入。

4.5 Pixtral 12B

Pixtral 12B(2024年9月17日)是 Mistral AI 的第一个多模态模型,它采用了方法A:统一嵌入解码架构。遗憾的是,目前没有公开的技术论文或报告,但 Mistral 团队在他们的博客中分享了一些有趣的细节。

有趣的是,他们选择不使用预训练的图像编码器,而是从头开始训练了一个拥有 4亿参数 的图像编码器。至于 LLM 的主干模型,他们使用了 12亿参数的 Mistral NeMo 模型。

类似于 Qwen2-VL,Pixtral 也原生支持可变图像大小,具体如下面的示意图所示。

Pixtral 如何处理不同大小图像的示意图

(注释图来源于 Pixtral 博客文章:
https://mistral.ai/news/pixtral-12b/)

4.6 MM1.5:多模态LLM微调的方法、分析与洞察

《MM1.5:多模态LLM微调的方法、分析与洞察》 论文(2024年9月30日)提供了许多实用建议,并引入了一种混合专家多模态模型以及类似 Molmo 的稠密模型。这些模型的规模范围从 1亿参数 到 300亿参数 不等。

该论文中描述的模型主要集中在方法A:统一嵌入变换器架构,这种架构能够有效地组织输入数据以进行多模态学习。

此外,论文还进行了多项有趣的消融研究,探讨了数据组合以及使用坐标标记(coordinate tokens)的效果。

MM1.5 方法的示意图,包括用于表示边界框的附加坐标标记

(注释图来源于 MM1.5 论文:
https://arxiv.org/abs/2409.20566)

4.7 Aria:一种开放的多模态原生混合专家模型

《Aria:一种开放的多模态原生混合专家模型》 论文(2024年10月8日)引入了另一种混合专家模型的方法,与 Molmo 和 MM1.5 系列中的某些变体类似。

Aria 模型拥有 24.9亿参数,其中每个文本标记分配了 3.5亿参数。图像编码器(SigLIP)则拥有 4.38亿参数。

该模型基于跨注意力方法,整体训练流程如下:

  1. 从零开始训练LLM主干模型。

  2. 同时预训练LLM主干和视觉编码器。

4.8 Baichuan-Omni

《Baichuan-Omni技术报告》(2024年10月11日)介绍了 Baichuan-Omni,这是一种基于方法A:统一嵌入解码架构的 70亿参数多模态LLM,如下图所示:

Baichuan-Omni 模型概述,可以处理多种输入模态

(注释图来源于 Baichuan-Omni 论文:
https://arxiv.org/abs/2410.08565)

Baichuan-Omni 的训练过程分为三阶段:

  1. 投影器训练:最初仅训练投影器,视觉编码器和语言模型(LLM)保持冻结状态。

  2. 视觉编码器训练:接下来解冻视觉编码器并进行训练,LLM 仍然冻结。

  3. 完整模型训练:最终解冻 LLM,允许整个模型进行端到端的训练。

该模型采用 SigLIP 视觉编码器,并引入 AnyRes 模块,通过降采样技术处理高分辨率图像。

尽管报告没有明确说明LLM的主干模型,但根据模型参数规模和命名规则,很可能是基于 Baichuan 7B LLM。

4.9 Emu3:下一标记预测是你唯一需要的

《Emu3:下一标记预测是你唯一需要的》 论文(2024年9月27日)提出了一种引人注目的替代扩散模型的图像生成方法,这种方法完全基于变换器解码器架构。虽然它不是传统意义上的多模态LLM(即专注于图像理解而非生成的模型),但 Emu3 非常有趣,因为它表明可以使用变换器解码器进行图像生成,而这通常是扩散方法的领域。(不过需要注意的是,以前也有类似的方法,比如《Autoregressive Model Beats Diffusion: Llama for Scalable Image Generation》。)

Emu3 是一种基于 LLM 的图像生成模型,可作为扩散模型的替代方案

(注释图摘自 Emu3 论文:
https://arxiv.org/abs/2409.18869)

研究人员从零开始训练了 Emu3,并使用
直接偏好优化(DPO)
方法将模型调整为符合人类偏好的结果。

该架构包含一个受 SBER-MoVQGAN 启发的视觉标记器(Vision Tokenizer)。核心 LLM 架构基于 Llama 2,但整个模型完全从零开始训练。

4.10 Janus:解耦视觉编码以实现统一的多模态理解与生

我们之前关注的是用于图像理解的多模态LLM,并在上文中通过 Emu3 介绍了一个图像生成的例子。现在,《Janus:解耦视觉编码以实现统一的多模态理解与生成》 论文(2024年10月17日)引入了一个框架,将理解和生成任务统一在一个LLM主干中。

Janus 的一个关键特性是解耦视觉编码路径,以应对理解和生成任务的不同需求。研究人员指出,图像理解任务需要高维语义表示,而生成任务则需要图像的局部细节和全局一致性。通过分离这些路径,Janus 能够有效处理这些不同的需求。

Janus 使用的统一仅解码器框架概述

(注释图摘自 Janus 论文:
https://arxiv.org/abs/2410.13848)

该模型使用了与 Baichuan-Omni 相似的 SigLIP 视觉编码器 处理视觉输入。在图像生成方面,它使用向量量化(VQ)标记器处理生成过程。Janus 的基础LLM为 DeepSeek-LLM,拥有 13亿参数。

Janus 模型的三阶段训练流程

(注释图摘自 Janus 论文:
https://arxiv.org/abs/2410.13848)

训练过程分为以下三个阶段:

  1. 阶段I:仅训练投影层和图像输出层,LLM、理解和生成编码器保持冻结状态。

  2. 阶段II:解冻LLM主干和文本输出层,使模型在理解和生成任务上进行统一的预训练。

  3. 阶段III:解冻整个模型,包括 SigLIP 图像编码器,进行监督微调,使模型全面整合和优化其多模态能力。

结论

正如你可能注意到的,我几乎完全跳过了模型和计算性能的对比。首先,在公共基准上比较 LLM 和多模态 LLM 的性能非常具有挑战性,因为普遍存在的数据污染问题,这意味着测试数据可能已包含在训练数据中。

此外,架构组件的差异如此之大,以至于很难进行真正的公平对比。所以,向 NVIDIA 团队致敬,他们开发了多种版本的 NVLM,至少使得仅解码器和跨注意力方法之间的比较成为可能。

无论如何,这篇文章的主要结论是:多模态 LLM 可以通过多种不同的方法成功构建。下图总结了本文中涉及的不同模型的组件和训练方法。

本文涉及的不同模型及其子组件和训练方法概述

希望你觉得这几篇文章具有教育意义,并且对多模态 LLM 的工作原理有了更深入的理解!

我曾经在2014年在随笔《
Winform开发框架之参数配置管理功能实现-基于SettingsProvider.net的构建
》介绍过基于.NET开发的参数配置管理界面,本篇随笔基于类似的效果,介绍在WxPython跨平台开发框架上使用
LabelBook
控件实现配置管理界面的效果。

1、参数配置管理界面的特点和 .NET 实现回顾

参数配置管理界面的特点主要体现在以下几个方面:

界面按照不同的功能模块或参数类别划分为多个部分,常见形式包括选项卡。

每个模块包含相关参数的设置控件,便于用户快速定位和修改特定设置。

界面逻辑清晰,便于维护。避免信息过于集中,提高可读性。

涉及不同类型的参数,例如布尔值(复选框)、枚举值(下拉菜单)、数值(文本框或滑块)、路径(文件选择器)等。可根据参数的用途选择适当的控件以提高用户体验。

提供保存和加载配置的功能,将配置保存到文件、数据库或云端,便于多次使用和分享。

一个好的参数配置管理界面会综合考虑这些特点,提供高效、直观且安全的操作体验,同时满足系统可扩展性的需求。

随笔《
Winform开发框架之参数配置管理功能实现-基于SettingsProvider.net的构建
》介绍过基于.NET开发的参数配置管理功能,如下界面所示。

它的实现是通过一个主窗体容器,如下所示,

然后再提供各个面板的整合形成一个多面板的配置管理界面。

2、基于WxPython跨平台开发框架的配置管理界面实现

在WxPython组件模块中,我们可以使用 wx.lib.agw.labelbook 的 LabelBook 控件实现配置管理界面的功能。

LabelBook
是 wxPython
AGW
库中的一个增强选项卡控件,类似于标准的
wx.Notebook
,但提供了更丰富的外观和功能。使用它来实现配置管理界面有以下优势:

LabelBook

wx.lib.agw.labelbook
模块的一部分。

我们来看看一个简单的使用案例,如下代码所示。

importwximportwx.lib.agw.labelbook as LBfrom wx.lib.agw.fmresources import *


classConfigApp(wx.Frame):def __init__(self, parent=None):
super().
__init__(parent, title="配置管理", size=(800, 600))#创建 LabelBook 控件 panel =wx.Panel(self)
labelbook
= LB.LabelBook(panel, agwStyle=LB.INB_LEFT |INB_FIT_LABELTEXT)#创建布局 sizer =wx.BoxSizer(wx.VERTICAL)
sizer.Add(labelbook,
1, wx.EXPAND)
panel.SetSizer(sizer)
#添加配置页面 self.add_pages(labelbook)
self.Layout()
self.SendSizeEvent()
defadd_pages(self, labelbook: LB.LabelBook):#创建图像列表 self.image_list = wx.ImageList(32, 32)
self.image_list.Add(
wx.ArtProvider.GetBitmap(wx.ART_NEW_DIR, wx.ART_OTHER, (
32, 32))
)
self.image_list.Add(
wx.ArtProvider.GetBitmap(wx.ART_ADD_BOOKMARK, wx.ART_OTHER, (
32, 32))
)
self.image_list.Add(
wx.ArtProvider.GetBitmap(wx.ART_CDROM, wx.ART_OTHER, (
32, 32))
)
labelbook.AssignImageList(self.image_list)
#示例页面 1: 基础设置 page1 =wx.Panel(labelbook)
wx.StaticText(page1, label
="基础设置内容", pos=(20, 20))
labelbook.AddPage(page1,
"基础设置", select=True, imageId=0)#示例页面 2: 网络设置 page2 =wx.Panel(labelbook)
wx.StaticText(page2, label
="网络设置内容", pos=(20, 20))
labelbook.AddPage(page2,
"网络设置", select=True, imageId=1)#示例页面 3: 高级设置 page3 =wx.Panel(labelbook)
wx.StaticText(page3, label
="高级设置内容", pos=(20, 20))
labelbook.AddPage(page3,
"高级设置", select=True, imageId=2)if __name__ == "__main__":
app
=wx.App(False)
frame
=ConfigApp()
frame.Show()
app.MainLoop()

简单的界面效果如下所示。

我们有一个主要的结构后,就可以设计它们的样式和面板的内容了。我们可以通过一个函数来设置它的相关颜色效果。

    defSetUserColours(self):"""设置LabelBook用户自定义颜色"""self.background= wx.Colour(132, 164, 213)
self.activetab
= wx.Colour(255, 255, 255)
self.tabsborder
= wx.Colour(0, 0, 204)
self.textcolour
=wx.Colour(0, 0, 0)
self.activetextcolour
=wx.Colour(0, 0, 0)
self.hilite
= wx.Colour(191, 216, 216)

self.book.SetColour(INB_TAB_AREA_BACKGROUND_COLOUR, self.background)
self.book.SetColour(INB_ACTIVE_TAB_COLOUR, self.activetab)
self.book.SetColour(INB_TABS_BORDER_COLOUR, self.tabsborder)
self.book.SetColour(INB_TEXT_COLOUR, self.textcolour)
self.book.SetColour(INB_ACTIVE_TEXT_COLOUR, self.activetextcolour)
self.book.SetColour(INB_HILITE_TAB_COLOUR, self.hilite)

我们让每个面板的创建独立一个函数来创建,如下所示。

        #添加页面
        self.email_panel =self.create_email_panel(self.book)
self.system_panel
=self.create_system_panel(self.book)

self.book.AddPage(self.email_panel,
"邮箱配置", True, imageId=0)
self.book.AddPage(self.system_panel,
"系统设置", True, imageId=1)

其中系统设置简单如下所示。

    defcreate_system_panel(self, parent):
panel
=wx.Panel(parent)
sizer
=wx.BoxSizer(wx.VERTICAL)#系统参数1 param1_sizer =wx.BoxSizer(wx.HORIZONTAL)
param1_label
= wx.StaticText(panel, label="参数1:")
self.param1_input
=wx.TextCtrl(panel)
param1_sizer.Add(param1_label, 0, wx.ALIGN_CENTER_VERTICAL
| wx.ALL, 10)
param1_sizer.Add(self.param1_input,
1, wx.EXPAND | wx.ALL, 10)#系统参数2 param2_sizer =wx.BoxSizer(wx.HORIZONTAL)
param2_label
= wx.StaticText(panel, label="参数2:")
self.param2_input
=wx.TextCtrl(panel)
param2_sizer.Add(param2_label, 0, wx.ALIGN_CENTER_VERTICAL
| wx.ALL, 10)
param2_sizer.Add(self.param2_input,
1, wx.EXPAND | wx.ALL, 10)#添加到面板 sizer.Add(param1_sizer, 0, wx.EXPAND)
sizer.Add(param2_sizer, 0, wx.EXPAND)

panel.SetSizer(sizer)
return panel

通过上面的各司其职,就可以很好的创建对应的界面控件输入及处理了,创建的界面效果如下所示。

由于配置页面可能有很多个,不同的业务参数设置处理可能也有所不同。我们设计一个配置界面基类:
MyConfigDialog

其他业务配置类,继承它即可具有默认的常规处理效果和实现,如我们有一个
FrmConfigSettings
的子类,关系图如下所示。

我们只需要再配置界面基类
MyConfigDialog
中定义创建添加页面的方法,留给子类实现即可,如下所示。

    defcreate_papges(self, parent: LB.LabelBook):"""创建页面 - 子类重写"""

如我们在子类
FrmConfigSettings
实现了添加页面的方法,如下所示。

    defcreate_papges(self, parent: LB.LabelBook):"""创建页面 - 子类重写"""
        #设置图像列表
        image_list = wx.ImageList(32, 32)
image_list.Add(get_bitmap(
"email", 32))
image_list.Add(get_bitmap(
"computer_key", 32)) #ART_INFORMATION image_list.Add(get_bitmap("book", 32))
self.set_image_list(image_list)

self.email_panel
=self.create_email_panel(self.book)
self.book.AddPage(self.email_panel,
"邮箱配置", True, imageId=0)

self.env_panel
=self.create_env_panel(self.book)
self.book.AddPage(self.env_panel,
"应用程序信息", False, imageId=1)

self.params_panel
=self.create_params_panel(self.book)
self.book.AddPage(self.params_panel,
"系统参数配置", False, imageId=2)

我们添加界面引入只定义的辅助类GridBagUtil 来简化添加的处理,如下代码是创建一个邮箱配置的界面。

    defcreate_email_panel(self, parent):"""创建邮箱配置页面"""panel=wx.Panel(parent, wx.ID_ANY)#创建一个 GridBagSizer
        grid_sizer = wx.GridBagSizer(2, 2)  #行间距和列间距为 5
        util = GridBagUtil(panel, grid_sizer, 4)

self.email
=ctrl.MyTextCtrl(panel)
self.pop3_server
=ctrl.MyTextCtrl(panel)
self.pop3_port
=ctrl.MyTextCtrl(panel)
self.smtp_server
=ctrl.MyTextCtrl(panel)
self.smtp_port
=ctrl.MyTextCtrl(panel)
self.username
=ctrl.MyTextCtrl(panel)
self.password
= ctrl.MyTextCtrl(panel, style=wx.TE_PASSWORD)
self.user_ssl
= ctrl.MyCheckBox(panel, label="使用SSL加密")

util.add_control(
"邮件账号", self.email, is_expand=True, is_span=True, border=10)
util.add_control(
"POP3服务器:", self.pop3_server, is_expand=True, border=10)
util.add_control(
"POP3端口号:", self.pop3_port, is_expand=True, border=10)
util.add_control(
"SMTP服务器:", self.smtp_server, is_expand=True, border=10)
util.add_control(
"SMTP端口号:", self.smtp_port, is_expand=True, border=10)
util.add_control(
"登录账号:", self.username, is_expand=True, is_span=True, border=10)
util.add_control(
"登录密码:", self.password, is_expand=True, is_span=True, border=10)
util.add_control(
"是否SSL加密:", self.user_ssl, is_expand=True, is_span=True, border=10)#让控件跟随窗口拉伸 grid_sizer.AddGrowableCol(1) #允许第二列拉伸 grid_sizer.AddGrowableCol(3) #允许第三行拉伸 panel.SetSizer(grid_sizer)
panel.Layout()
return panel

最终界面效果如下所示。

对比一下之前的界面效果,整体效果各有千秋吧。

上面的WxPython的参数配置管理界面中,我设计了几个不同的参数管理,包括对ini文件的处理,.env配置文件的读取,以及基于系统在后台数据库的参数管理界面实现多个不同内容的管理。

如对于邮箱的相关配置信息,我们存储在INI文件中,因此创建一个INI文件存取信息的辅助类来处理即可。

显示数据的时候,通过调用辅助类来实现数据的显示,如下是邮件参数的读取显示函数。

    defdisplay_email_data(self):"""显示邮箱配置数据"""
        #print(settings.AppDir)
        filepath = settings.AppDir + "/" +self.filepath
ini_setting
=IniSettingUtil(filepath)

self.email.SetValue(ini_setting.get(self.section,
"email", ""))
self.pop3_server.SetValue(ini_setting.get(self.section,
"pop3_server", ""))
self.pop3_port.SetValue(ini_setting.get_int(self.section,
"pop3_port", 0))
self.smtp_server.SetValue(ini_setting.get(self.section,
"smtp_server", ""))
self.smtp_port.SetValue(ini_setting.get_int(self.section,
"smtp_port", 0))
self.username.SetValue(ini_setting.get(self.section,
"username", ""))
self.password.SetValue(ini_setting.get(self.section,
"password", ""))
self.user_ssl.SetValue(ini_setting.get_bool(self.section,
"ssl", False))

而程序的相关参数信息,我们通过Pydantic_Setting的进行加载到系统的配置类中的,那么直接读取即可。

    defdisplay_env_data(self):"""显示应用程序信息数据"""self.app_name.SetValue(settings.APP_NAME)
self.app_version.SetValue(settings.APP_VERSION)
self.app_desc.SetValue(settings.DESCRIPTION)
self.app_baseapi.SetValue(settings.API_BASE_URL)
self.app_unit.SetValue(settings.APP_UNIT)
self.app_author.SetValue(settings.App_Author)
self.app_email.SetValue(settings.App_Email)

关于settings是如何来的,可以了解一下Pydantic_Setting的处理方式,它是一个参数类的实例,可以读取目录下的.env配置参数到类里面作为属性处理。

classSettings(BaseSettings):"""系统信息配置类"""model_config=SettingsConfigDict(
env_file
=f"{BasePath}/.env", #加载env文件 extra="ignore", #加载env文件,如果没有在Settings中定义属性,也不抛出异常 env_file_encoding="utf-8",
env_prefix
="",
case_sensitive
=False,
)
#应用程序信息(项目名称、版本、描述等),从.env文件中读取 APP_NAME: str = "wxpython-Project"APP_VERSION: str= "1.0.0"DESCRIPTION: str= "本项目是基于 wxPython 开发的 GUI 应用。"API_BASE_URL: str= "http://localhost:8000/api/"APP_UNIT: str= "广州爱奇迪软件科技有限公司" #单位名称 App_Author: str = "伍华聪" #项目作者 App_Email: str = "" #项目作者邮箱

如果.env没有值,那么就使用这里面的默认值,如有,则加载.env中的参数值。

另外,我们系统提供了一个常用的参数管理模块,也可以整合在其中进行显示。

这个模块直接是通过API进行远程读取获取数据显示的。

    async defdisplay_params_data(self):"""显示参数配置数据"""company_name= await api_systemparam.get_by_name("公司名称")
address
= await api_systemparam.get_by_name("公司地址")
contact
= await api_systemparam.get_by_name("公司联系人")
invoice
= await api_systemparam.get_by_name("开票信息")

self.param_company.SetValue(company_name.content)
self.param_address.SetValue(address.content)
self.param_contact.SetValue(contact.content)
self.param_invoice.SetValue(invoice.content)

以上就是我们在做WxPython跨平台的配置管理界面中,实现的思路和处理过程,通过抽象基类的方式,减少常用的代码和逻辑,并提供很好的扩展。

Hangfire 是一个开源的 .NET 任务调度框架,它允许开发人员轻松地将长时间运行的任务、定时任务和其他后台处理从主线程中分离出来,以提高应用程序的响应速度和性能

1. 安装 Hangfire

首先,需要一个 ASP.NET Core 项目。通过 NuGet 安装 Hangfire:

Install-Package Hangfire

同时,根据需要安装 Hangfire 支持的数据库驱动,例如 SQL Server 或 Redis。

2. 配置 Hangfire


Startup.cs
文件中配置 Hangfire:

public void ConfigureServices(IServiceCollection services)
{
    // 添加 Hangfire 服务,并配置使用 SQL Server 作为持久化存储
    services.AddHangfire(x => x.UseSqlServerStorage("connection_string"));
    // 添加 Hangfire 服务器,后台处理任务的核心服务
    services.AddHangfireServer();
}

public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs)
{
    // 使用 Hangfire Dashboard,允许在浏览器中查看任务的执行情况
    app.UseHangfireDashboard();
    // 创建一个 Fire-and-forget 类型的后台任务,即立即执行一次
    backgroundJobs.Enqueue(() => Console.WriteLine("Hello Hangfire!"));
}

3. Hangfire 的任务类型

  • 立即执行的任务(Fire-and-forget)
  var backgroundJobs = app.Services.GetService<IBackgroundJobClient>();
 // 解析服务并使用 
 // 创建一个 Fire-and-forget 类型的后台任务,即立即执行一次
 backgroundJobs.Enqueue(() =>
 Console.WriteLine("Hello Hangfire!,后台任务,即立即执行一次"));
  • 延迟任务(Delayed jobs)
 // 延迟任务,1 分钟后执行
backgroundJobs.Schedule(() => 
Console.WriteLine("延迟任务,1 分钟后执行"),
TimeSpan.FromMinutes(1));

  • 周期性任务(Recurring jobs)
//周期性任务,每天执行一次
RecurringJob.AddOrUpdate("easyjob", () =>
Console.WriteLine("Easy!:周期性任务,每天执行一次"), 
Cron.Daily);

//周期性任务 ,corn表达式
RecurringJob.AddOrUpdate("powerfuljob", 
    () => Console.WriteLine("Powerful!,周期性任务:每秒执行一次"),
    "0/1 * * * * ? "); //每秒执行一次【https://cron.ciding.cc】

4. 访问 Hangfire Dashboard

app.UseHangfireDashboard();
配置完成后,你可以通过访问
http://localhost:5000/hangfire
来查看 Hangfire Dashboard,监控任务的执行情况。

5. 安全性

在生产环境中,你需要为 Hangfire Dashboard 设置权限验证。可以通过实现
IDashboardAuthorizationFilter
接口来自定义授权逻辑。

Install-Package Hangfire.Dashboard.Authorization
public class BasedAuthorizationFilter : IDashboardAuthorizationFilter
{ 
    public bool Authorize([NotNull] Hangfire.Dashboard.DashboardContext context)
    {
        if (context.GetHttpContext().Request.Host.ToString().StartsWith("localhost"))
        {
            return true;
        }

        // 检查 context 参数是否为 null
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        // 获取当前请求的用户标识
        var httpContext = context.GetHttpContext();
        var authUser = httpContext?.User;

        // 这里可以根据需要实现具体的授权逻辑
        // 检查用户是否在某个角色内,或者是否有特定的权限标记
        bool isAuthorized = authUser?.Identity?.IsAuthenticated == true
                           && authUser.IsInRole("YourRoleName"); // 替换 "YourRoleName" 为实际的角色名称

        return isAuthorized;
    }
 }

  app.UseHangfireDashboard("/hangfire", new DashboardOptions
 {
     Authorization = new List<IDashboardAuthorizationFilter> { new BasedAuthorizationFilter() }
 }); // 设置 Dashboard 的 URL

6. Hangfire 扩展:HttpJob

Hangfire.HttpJob 是一个扩展组件,它允许将 Hangfire 的任务调度和业务逻辑分离。这意味着业务逻辑可以作为独立的 Web API 暴露给 Hangfire 进行调度,从而实现解耦。下面是一些关键点:

  • 项目地址

    Hangfire.HttpJob GitHub
  • 目的
    :剥离 Job 调度和业务,使得业务开发者可以忽略 Hangfire 的存在,不同业务线可以独立部署 Job 代理,互不影响。
  • 技术特性
    :支持延迟任务、周期性任务、任务管理、Cron 生成器和任务代理扩展。
  • 应用场景
    :适用于定时任务、后台作业、周期性任务和微服务架构中的任务调度。

7. 其他资源


Streamlit
应用开发中,
Fragments
组件是一种用于更精细地控制页面元素更新和显示顺序的工具。

它允许开发者将内容分解成多个小的片段,这些片段可以按照特定的顺序或者逻辑进行更新,而不是一次性地更新整个页面或容器中的所有内容。

这为创建动态且交互性强的用户界面提供了更多的灵活性和控制力。

1. 概要

Fragments
有点像
Web2.0
时的
Ajax
技术,它能够把页面内容拆分成多个小片段,就像是把一幅完整的画分成了许多小拼图。

这样的好处是,
Fragments
可以对更新操作进行细分,仅更新部分内容,提升页面响应速度。

从本质上说,它为开发者在构建动态、交互性强的应用界面时,提供了更高的灵活性和精准的内容控制能力。

Fragments
组件的参数不多,使用时一般通过
st.fragment
装饰器来编写小片段,后续会通过示例来演示。

名称 类型 说明
func 函数对象 将其转换为片段的函数,
@st.fragment
装饰器所装饰的函数
run_every 整数、浮点数、时间间隔、字符串或None None
(默认值):片段仅在用户触发事件时重新运行。
整数或浮点数
:指定以秒为单位的时间间隔,例如5表示每 5 秒自动重新运行片段。
字符串
:指定时间格式,如"1d"(1 天)、"1.5 days"(1.5 天)或"1h23s"(1 小时 23 秒),该格式被 Pandas 的Timedelta构造函数支持。
timedelta对象
(来自 Python 的内置datetime库):如timedelta(days=1)表示每天自动重新运行片段。

2. Fragments 和 Form 区别

Fragments
和上一篇介绍的
Form
看起来很类似,都是将多个关联的组件组织起来,统一更新和管理。

实际上,它们的应用场景和工作方式区别很大,了解其中的区别,可以让我们更好的选择相应的组件。

2.1. 主要用途


用途
上来看,
Fragments
主要用于:

  • 实现引导式内容展示
    :用于创建引导性的应用界面,以逐步展示信息。例如,在一个数据分析应用的教程中,先使用
    Fragments
    展示数据加载的步骤,然后再展示数据分析方法的介绍。
  • 优化页面更新性能
    :在处理大量数据或复杂 UI 更新时,
    Fragments
    可以将更新操作拆分成多个小片段更新。每次只刷新必要的部分,提高应用的响应速度。比如,在一个实时数据监控应用中,使用
    Fragments
    可以分别更新不同数据图表的部分,而不是一次性更新整个页面的所有图表。
  • 构建复杂交互逻辑
    :对于具有复杂交互逻辑的应用,
    Fragments
    能够帮助组织和控制页面元素的显示与隐藏。例如,在一个多步骤的操作流程应用中,通过
    Fragments
    管理每个步骤中不同操作按钮和提示信息的显示和隐藏。


Form
则主要用于:

  • 数据收集
    :主要用于收集用户输入的数据。这可以是简单的文本信息,也可以是复杂的选择,如在一个产品配置表单中,用户通过下拉菜单选择产品型号、颜色等选项。
  • 数据验证和提交
    :表单通常包含数据验证机制,以确保用户输入的数据符合要求。并且,表单提供了
    提交功能
    ,将收集到的用户数据发送到服务器或者进行本地处理。

2.2. 工作方式

从组件的
工作方式
来看,
Fragments
本身并不具有像表单那样固定的结构。

它更像是一个容器,可以容纳各种
Streamlit
组件,如文本、按钮、图表等。可以通过代码逻辑来控制这些组件在
Fragments
中的显示顺序和条件。


表单
具有比较明确的结构,通常包含
form
标签(在 HTML 层面)和一系列的输入组件,如
st.text_input

st.selectbox
等。

表单中的所有输入组件通常是相互关联的,它们共同构成了一个数据收集单元。

而且,表单可以通过
st.form_submit_button
来触发提交操作,并且可以使用
st.form
上下文管理器来确保表单内的组件数据在提交时能够正确地一起处理。

2.3. 数据处理


数据处理
方面,
Fragments
相对灵活,例如,在一个包含多个
Fragments
的应用中,

每个
fragment
可能有自己独立的按钮,点击按钮后的既可以更新当前
fragment
,也可以触发其他
fragment
的更新。

数据交互更多地体现在不同
fragment
之间的切换和内容更新上。


Form
在数据处理主要围绕用户输入的数据,在表单提交后,通常会对收集到的数据进行验证、清洗和存储等操作。

例如,将用户在表单中输入的注册信息发送到数据库进行存储,或者根据用户在表单中选择的查询条件从数据库中获取数据并显示。

表单内的交互主要集中在用户输入和提交操作上,以及根据输入数据的合法性给予用户相应的反馈,如提示输入错误信息等。

3. Fragments使用示例

下面通过两个根据实际情况简化的示例来演示
Fragments
的使用场景。

3.1. 逐步显示信息

在这个示例中,我们创建了一个产品介绍页面,通过
Fragments
组件将信息分成三个步骤逐步显示。

首先显示欢迎信息,当用户点击 【
点击了解更多
】按钮后,显示产品功能信息,

再点击【
继续下一步
】按钮后,显示产品优势信息。

import streamlit as st

@st.fragment
def step1():
    st.write("欢迎来到我们的产品介绍页面。")
    if st.button("点击了解更多", key="step1"):
        step2()


@st.fragment
def step2():
    st.write("这是我们产品的主要功能:功能 1、功能 2、功能 3。")
    if st.button("继续下一步", key="step2"):
        step3()


@st.fragment
def step3():
    st.write("最后,这是我们产品的优势所在,如高效性、易用性等。")
    if st.button("刷新", key="step3"):
        st.rerun()


# 初始显示第一个片段
step1()

3.2. 局部刷新页面

这个示例模拟一个数据展示大屏,大屏被分成两列,分别展示
销售数据

流量数据

每个数据区域都有自己的刷新按钮,点击按钮时,只会重新运行对应的
fragment
函数,从而实现该区域数据的局部刷新,不会影响其他部分。

import streamlit as st
import random


# 模拟获取一些数据
def get_sales_data():
    return random.randint(100, 1000)


def get_traffic_data():
    return random.randint(50, 500)


# 创建销售数据展示片段
@st.fragment
def sales_data_fragment():
    st.subheader("销售数据")
    # 获取并显示销售数据
    sales_data = get_sales_data()
    st.write(f"今日销售额: {sales_data}")
    # 销售数据的局部刷新
    if st.button("刷新销售数据", key="sales"):
        st.rerun(scope="fragment")


# 创建流量数据展示片段
@st.fragment
def traffic_data_fragment():
    st.subheader("流量数据")
    # 获取并显示流量数据
    traffic_data = get_traffic_data()
    st.write(f"今日流量: {traffic_data}")
    # 流量数据的局部刷新
    if st.button("刷新流量数据", key="traffic"):
        st.rerun(scope="fragment")


# 主函数,在大屏上排列各个片段
def main():
    # 将屏幕划分为两列,分别放置不同的片段
    col1, col2 = st.columns(2)
    with col1:
        sales_data_fragment()
    with col2:
        traffic_data_fragment()


if __name__ == "__main__":
    main()

4. 总结

最后,总结下应该在什么时候可以考虑选择
Fragments
来构建我们的
streamlit
应用。

首先,需要
逐步显示信息
时,可以考虑使用
Fragments

例如在创建一个教程或者引导性的应用界面时,先显示一部分说明文字,然后在用户进行某个操作(如点击按钮)后再显示下一步的内容,而不是一次性将所有教程信息都呈现给用户,避免信息过载,影响用户的理解和操作体验。

其次,
优化页面更新性能
时,可以考虑使用
Fragments

比如,当应用中有大量数据或者复杂的 UI 更新时,
Fragments
组件可以将更新操作拆分成多个小的片段更新。这样,在每次更新时只刷新必要的部分,减少了页面重新渲染的工作量,从而提高应用的响应速度和性能。

还有
构建复杂交互逻辑
时,也可以考虑使用
Fragments

在构建具有复杂交互逻辑的应用时,
Fragments
组件能够帮助开发者更好地组织和控制页面元素的显示与隐藏。

Taro 一直以来都没有一个能兼容 RN 的动画方案,duxapp 中扩展了 createAnimation 方法,让这个方法兼容了 RN 端,下面让我们来看看实现思路

createAnimation方法

这个方法是用来创建一个动画实例的,使用方法像下面这样,每次 step 创建一组动画,每组动画同时执行,执行完一组继续执行下一组,直到所有的动画执行完

const an = createAnimation()
  .translate(50, 50).rotate(180).step()
  .translate(0, 0).rotate(0).step()
  .export()

将创建的结果设置给 View 的
animation
属性,这个动画就能被执行了

在 Taro 里面这个方法目前兼容小程序 和 H5 端,我们要实现的就是让他兼容 RN,这样后面要给我们的组件加动画就更加简单了,不用在要加动画的组件 H5 写一套,RN 再写一套

RN的动画方案

RN 里面的动画和
createAnimation
这个方式可以说是天差地别,下面来看这个实现淡入动画的代码,这是一个官方示例

import React, { useRef, useEffect } from 'react';
import { Animated, Text, View } from 'react-native';

const FadeInView = (props) => {
  const fadeAnim = useRef(new Animated.Value(0)).current  // 透明度初始值设为0

  React.useEffect(() => {
    Animated.timing(                  // 随时间变化而执行动画
      fadeAnim,                       // 动画中的变量值
      {
        toValue: 1,                   // 透明度最终变为1,即完全不透明
        duration: 10000,              // 让动画持续一段时间
      }
    ).start();                        // 开始执行动画
  }, [fadeAnim])

  return (
    <Animated.View                 // 使用专门的可动画化的View组件
      style={{
        ...props.style,
        opacity: fadeAnim,         // 将透明度绑定到动画变量值
      }}
    >
      {props.children}
    </Animated.View>
  );
}

好在 RN 的动画库本身是很强大的,我们要做的是在 RN 端模拟实现一个
createAnimation
, 还是有办法解决的

实现

要实现 RN,我们需要两步

  • 1、编写一个类,用来模拟
    createAnimation
    方法,通过这个类创建一些动画数据
  • 2、将这个类创建的数据传递给一个自定义组件,这个组件里面将这些数据解析成动画,并执行这些动画

创建 Animation 类

这个类就比较简单,模拟 createAnimation 每一个方法即可,并在
export()
之后生成一个数据并返回,因代码过长,下面是部分代码展示

export const create = option => new Animation(option)

class Animation {

  constructor(option = {}) {
    if (!option.duration) {
      option.duration = 400
    }
    if (!option.timingFunction) {
      option.timingFunction = 'linear'
    }
    if (!option.delay) {
      option.delay = 0
    }
    if (!option.transformOrigin) {
      option.transformOrigin = '50% 50% 0'
    }
    this.defaultOption = option
  }

  result = []

  current = {}

  export() {
    const res = this.result
    this.result = []
    return res
  }

  step(option) {
    if (Object.keys(this.current).length) {
      this.result.push({
        option: {
          ...this.defaultOption,
          ...option
        },
        action: this.current
      })
      this.current = {}
    }
    return this
  }

  set(name, value) {
    this.current[name] = value
    return this
  }

  translate(x, y) {
    this.translateX(x)
    return this.translateY(y)
  }

  translate3D(x, y, z) {
    this.translateX(x)
    this.translateY(y)
    return this.translateZ(z)
  }

  translateX(val) {
    return this.set('translateX', val)
  }

  translateY(val) {
    return this.set('translateY', val)
  }

  translateZ(val) {
    return this.set('translateZ', val)
  }
}

创建组件实现动画

这个地方相对会复杂一些,其中的难点有几个

  • 在小程序上的动画,是会根据 css 的默认值去执行变化的,但是 RN 上的默认值需要在动画中设置,因此,需要获取这个默认值
  • 将动画拆分成适合 RN 端动画组件的 style 属性

此处代码过长,可以前往
github
查看,或者使用 duxapp 创建项目之后就能看到

使用

动画库写好之后我们就能给我们原有的一些组件进行改造了,例如 PullView,在这之前,这个组件是针对 RN 端和其他端写了两套代码的,现在只需要一套代码就实现了,下面的示例代码展示了这个组件动画实现方式

import { isValidElement, cloneElement, forwardRef, useState, useEffect, useRef, useImperativeHandle, useCallback } from 'react'
import { View } from '@tarojs/components'
import { asyncTimeOut, nextTick, px, pxNum, transformStyle, useBackHandler } from '@/duxapp/utils'
import { Absolute } from '../Absolute'
import { Animated } from '../Animated'
import './index.scss'

export const PullView = forwardRef(({
  side = 'bottom',
  style,
  overlayOpacity = 0.5,
  children,
  masking = true,
  group,
  onClose,
  modal,
  mask = modal,
  duration = 200
}, ref) => {

  const [mainAn, setMainAn] = useState(Animated.defaultState)

  const [maskAn, setMaskAn] = useState(Animated.defaultState)

  const ans = useRef()

  const refs = useRef({})
  refs.current.onClose = onClose
  refs.current.overlayOpacity = overlayOpacity

  const translate = siteTranslates[side]

  const close = useCallback(async () => {
    let an = ans.current.main
    if (side === 'center' && process.env.TARO_ENV !== 'rn') {
      an = an.translate('-50%', '-50%')
    }
    setMainAn(an[translate.key](pxNum(translate.value)).opacity(0).step(
      process.env.TARO_ENV !== 'rn' ? {
        transformOrigin: '25% 25% 0'
      } : {}
    ).export())
    setMaskAn(ans.current.mask.opacity(0).step().export())
    await asyncTimeOut(duration)
    refs.current.onClose?.()
  }, [duration, side, translate.key, translate.value])

  useBackHandler(close, !mask)

  useImperativeHandle(ref, () => {
    return {
      close
    }
  })

  useEffect(() => {
    nextTick(() => {
      if (!ans.current) {
        ans.current = {
          main: Animated.create({
            duration,
            timingFunction: 'ease-in-out'
          }),
          mask: Animated.create({
            duration,
            timingFunction: 'ease-in-out'
          })
        }
      }
      if (side === 'center') {
        let an = ans.current.main.scale(1).opacity(1)
        if (process.env.TARO_ENV !== 'rn') {
          an = an.translateX('-50%').translateY('-50%')
        }
        setMainAn(an.step().export())
      } else {
        setMainAn(ans.current.main.translateX(0).translateY(0).opacity(1).step().export())
      }
      setMaskAn(ans.current.mask.opacity(refs.current.overlayOpacity).step().export())
    })
  }, [duration, side])

  return <Absolute group={group}>
    {masking && <Animated.View
      animation={maskAn}
      className='PullView'
    >
      <View className='PullView__other'
        onClick={() => {
          if (mask) {
            return
          }
          close()
        }}
      ></View>
    </Animated.View>}
    <Animated.View
      animation={mainAn}
      className={`PullView__main PullView__main--${side}`}
      style={{
        ...style,
        transform: transformStyle(side === 'center' ? {
          translateX: '-50%',
          translateY: '-50%',
          scaleX: 0.4,
          scaleY: 0.4
        } : {
          [translate.key]: px(translate.value)
        })
      }}
    >
      {
        isValidElement(children) ?
          cloneElement(children, {
            style: {
              ...children.props.style,
              ...(side === 'center'
                ? {}
                : side === 'bottom' || side === 'top'
                  ? { width: '100%' }
                  : { height: '100%' })
            }
          }) :
          children
      }
    </Animated.View>
  </Absolute>
})

const siteTranslates = {
  top: { key: 'translateY', value: -200 },
  bottom: { key: 'translateY', value: 200 },
  left: { key: 'translateX', value: -200 },
  right: { key: 'translateX', value: 200 },
  center: { key: 'scale', value: 0.4 }
}

最后

当然这个动画也不是完美的,只是实现了一个基础的动画,甚至使用的时候还有诸多的限制,你可以点击下面的动画文档查看详细的使用方法以及限制

Animated 动画文档

开发文档
GitHub