2025年8月

summary-icon摘要

由AI智能服务提供

本文回顾了Python插件开发的前几篇内容,并重点介绍了单据转换插件的开发。单据转换是业务流程中的重要环节,通过插件可以控制单据的转换过程,调整生成的下游数据包。文章详细阐述了单据转换的两种类型(下推和选单)及其实现逻辑,并介绍了单据转换插件的注册、事件处理流程以及常用事件的处理方法。通过示例代码展示了如何在Python中实现单据转换插件,并提供了应用案例和事件处理示例。最后,感谢读者的关注与支持,并预告了下一篇关于插件常用工具类的分享。

有用

反馈

往期回顾:

【Python插件入门】第1篇:Python插件入门讲解

【Python插件入门】第2篇:基本开发过程介绍

【Python插件入门】第3篇:插件中如何进行数据操作

【Python插件入门】第4篇:单据表单插件

【Python插件入门】第5篇:单据列表插件

【Python插件入门】第6篇:操作服务插件

【Python插件入门】第7篇:简单账表服务插件

【Python插件入门】第8篇:账表表单插件

      

        前面的篇章讲解了各种类型的插件,这些插件都是在单个业务对象中触发使用,我们同时也了解了对单据数据的操作方法,是不是对这个系统的功能背后的实现逻辑有了更深的理解呢,当不同的业务对象"串联起来"就构成了系统中各种丰富的功能,形成了系统的业务流程,在"串联"各种单据的过程中,必不可少的就是"单据转换",他是业务流程连接过程中的一个重要枢纽,对于单据转换的过程 ,系统也支持了通过插件对这个过程进行干预,今天我们就来看一看如何用Python开发单据转换插件。

一、单据转换概述

  • 单据转换,是指把上游所选单据,按照转换规则,自动生成下游单据数据包的过程;不对下游单据进行保存、提交、审核等处理。

  • 单据转换插件,能够介入到单据转换的各个关键时刻,对转换行为进行控制,从而调整所生成的下游数据包;

  • 单据转换按照发起方不同,可分为下推、选单;

    下推是指在上游单据列表,把所选单据,生成下游单据数据包,并展示出来;

    选单是指在下游单据新增界面,弹出上游单据列表,选择源单返回,然后根据转换规则把源单数据填写到下游单据新增界面上;

  • 选单实际上分为两个独立的过程,一个是选单前过程,根据转换规则,生成源单数据筛选条件,传给源单列表。因此源单列表上显示的,都是允许下推的数据;另外一个就是选单过程,把用户选择返回的源单数据,迁移到目标单据上;

  • 下推与选单,采用相同单据转换规则,进行数据迁移;也采用相同的单据转换插件,但触发的事件略有差异,编写单据转换插件时,需要兼顾这些差异; 

二、单据转换规则简介

以下知识结构截图摘取自@eris 老师分享的学习资料,在此感谢老师的用心制作!

也强烈推荐大家看看老师的讲解文章和课程!金蝶云星空BOS专题中级课-业务流程

    单据转换的核心是单据转换规则基于单据转换规则才会有单据转换插件。单据转换规则配置可以实现我们常见的单据流转需求,我们先来简单回顾一下单据转换规则。

  • 单据关联配置:要实现关键关联,需要先在下游单据中设置单据关联配置

image.png


  • 单据转换规则说明

image.png

  • 单据转换规则策略要点

image.png

  • 可以触发单据转换规则的操作

image.png

三、单据转换插件介绍

    C#开发时,单据转换插件的基类是AbstractConvertPlugIn。Python插件在单据转换规则-插件策略中注册即可。

    单据转换插件开始支持Python插件的版本是PT-146836 [7.5.1.202005],高于此版本,应该都是可以使用的。

    单据转换插件是依赖于单据转换规则来触发的,单据转换从选择单据、指定单据类型、分组合并、过滤携带数据等整个转换过程都提供了插件干预方法,应对一些复杂的单据转换场景需求。所以只要触发了单据转换规则,就可以触发单据转换插件执行过程。

  • 单据下推执行过程

image.png

image.png

  • 单据选单执行过程

image.png

  • 单据转换插件一览图(含事件执行顺序)

image.png


  • 选单时,单据转换插件事件执行顺序:与下推有小部分差异

image.png

这里要说明的是,下推、选单前、选单过程,采用的插件是同一个。 从事件的覆盖度来看,下推过程触发的事件是最全面的,针对下推过程编写的插件,选单过程被自动覆盖。也就是说通常情况下,在不对选单界面做特殊干预的情况下,按照下推的运行过程来开发一个单据转换插件,对选单操作也是通用的。

四、单据转换插件常用事件

        前面的截图中已经将单据转换插件执行过程讲得比较清晰了,总的来说单据转换插件也是按照一条"流水线"执行的,我们只需要在需要干预的环节,去实现对应的事件,加入我们的代码逻辑就可以了。

  • 我们再整体看一下下推运行时序

image.png

  • 新建一个单据转换插件,以采购申请下推采购订单为例

image.png

  • 下面看看单据转换插件中的一些常用事件

#解析字段映射关系,并构建查询参数。
#这里可以加入你想要的额外的字段
def OnQueryBuilderParemeter(e):
    #插件常用全局属性,顺带介绍一下,不一定在此事件中使用  #*************************************************************************************************************
    #this.OperationNumber;#操作编码,如下推,选单操作等,值分别为Push,Draw,
    #paraDIC=this.Option.GetVariables();#获取自定义参数集,字典类型,例如是否整单下推、是否需要校验下游单据新增权限、WebAPI调下推时传入的自定义参数等等
    #if(paraDIC.ContainsKey("参数标识")==True):#判断是否含有某个参数
    #    paraValue=paraDIC["参数标识"];#取出参数标识
    #***********************************************************************************************************
    #e.SourceBusinessInfo;#上游单据的元数据信息,可从这里获取一些上游单据的关键信息
    # secFormId=e.SourceBusinessInfo.GetForm().Id;#上游单据FormId
     billNo=SelectorItemInfo("FBillNo");#加入单据转化规则中没有配置的,但需要额外加载的字段
     e.SelectItems.Add(billNo);

#解析过滤策略中配置的条件,可以在这里加自定义的下推过滤条件
#此事件开始前,刚完成选单条件策略中的过滤条件的解析
#e.FilterPolicySQL来自于:【选单条件策略中设置的过滤条件(JsonSetting)】+【选单条件策略中的附加条件(CustFilter)】+【按目标组织基础资料属性过滤的条件(TargetOrgBDFilterList)】
#此事件结束后,e.FilterPolicySQL将使用AND操作符合并到QueryBuilderParemeter.FilterClauseWihtKey,后续作用于选单列表取数
def OnParseFilter(e):
    filterStr=" FBillNo LIKE 'CGSQ%' "
    e.FilterPolicySQL=StringUtils.JoinFilterString(e.FilterPolicySQL,filterStr);#追加过滤条件,默认用AND连接
    e.PlugFilterDesc = "单据编号必须包含【CGSQ】";#过滤条件描述信息,下推不满足时,会提示出来。

#选单时才有,解析字段映射关系中配置的过滤选项:过滤/仅追加
#此事件开始前,刚完成用于选单列表取数的QueryBuilderParemeter的构建,e.FilterOptionsSQL来自于QueryBuilderParemeter.FilterClauseWihtKey
def OnParseFilterOptions(e):
    #e.TargetData;#目标单的数据包
    #e.SourceBusinessInfo;#源单元数据信息
    #e.TargetBusinessInfo;#目标单元数据信息
    filterStr=" FReqQty > 10 ";
    e.FilterOptionsSQL = StringUtils.JoinFilterString(e.FilterOptionsSQL,filterStr);#追加选单过滤条件

#获取到源单数据之后触发,(下推执行)
#可以在此事件中修改源单下推的数据包,不会真实修改源单,只会影响下推的携带值
#例如,可以动态修改源单分单依据字段的值,以此实现动态分单策略
def OnGetSourceData(e):
    #e.SourceBusinessInfo;#源单元数据信息
    srcData=e.SourceData;#源单数据,本次下推的所有数据行,DynamicObjectCollection类型
    #可循环从数据包集合中取数,只能取到参与转换规则中配置的字段和OnQueryBuilderParemeter额外加入的字段
    for row in srcData:
        srcId=row["Id"];#源单Id
        srcEntryId=str(dr["FEntity_FEntryID"]);#源单分录内码:单据体标识_FEntryId

#获取到源单数据之后触发,(选单执行)
#与OnGetSourceData类似,只不过一个是在下推执行,一个是选单执行
def OnGetDrawSourceData(e):
    #e.SourceBusinessInfo;#源单元数据信息
    srcData=e.SourceData;#源单数据,本次下推的所有数据行,DynamicObjectCollection类型
    #可循环从数据包集合中取数,只能取到参与转换规则中配置的字段和OnQueryBuilderParemeter额外加入的字段
    for row in srcData:
        srcId=row["Id"];#源单Id
        srcEntryId=str(dr["FEntity_FEntryID"]);#源单分录内码:单据体标识_FEntryId

#执行分组前触发,可以在此增加自定义的分组字段
def OnBeforeGroupBy(e):
    srcData= e.SourceData;#源单数据
    HeadGroupKey = e.HeadGroupKey;#分单依据
    e.HeadGroupKey =("{0},{1}").format(e.HeadGroupKey,"FXXX");#追加分组字段
    EntryGroupKey=e.EntryGroupKey;#单据体分组合并字段
    e.EntryGroupKey =("{0},{1}").format(e.EntryGroupKey,"FEXXX");#追加单据体分组合并字段
    SubEntryGroupKey=e.SubEntryGroupKey;#子单据体分组字段
    e.SubEntryGroupKey =("{0},{1}").format(e.SubEntryGroupKey,"FEXXX");#追加单据体分组字段

#下推执行,根据分组策略创建目标单,此时还没根据字段映射赋值目标单的字段,这个事件用得不多
#也没有创建关联数据包,接近一个"空白"的下游单据,当然有默认值的字段还是有值
def OnCreateTarget(e):
    headEntity=e.TargetExtendedDataEntities.FindByEntityKey("FBillHead");#分组之后创建的所有目标单集合
    for entity in headEntity:
        billObj=entity.DataEntity;#下游单据的完整数据包,可以通过实体数据包操作方式处理下游单据字段

#选单执行,与OnCreateTarget类似,此时获取到了选单返回的数据,但是还没赋值到下游单据的字段上
def OnCreateDrawTarget(e):
    headEntity=e.TargetExtendedDataEntities.FindByEntityKey("FBillHead");#获取根据分组创建好的目标单

#目标单赋值之前触发,主要用启动字段赋值事件OnFieldMapping,用于监听字段映射赋值过程,用得很少
def OnBeforeFieldMapping(e):
    e.FireFieldMappingEvent=True;#启动字段赋值
    
#字段赋值事件,每个数据包,每个字段都会触发,易影响性能,慎用!
def OnFieldMapping(e):
    headEntity=e.TargetExtendedDataEntities.FindByEntityKey("FBillHead");#下游单据数据集
    fldKey=e.TargetField.Key.ToUpperInvariant();#赋值字段标识大写

#字段映射所有字段赋值完成之后触发,用得很少    
def OnAfterFieldMapping(e):
    headEntity=e.TargetExtendedDataEntities.FindByEntityKey("FBillHead");#下游单据数据集

#创建关联关系之前触发,可以取消创建,用得很少
def OnCreateLink(e):
    headEntity=e.TargetExtendedDataEntities.FindByEntityKey("FBillHead");#下游单据数据集
    #e.Cancel=True;#取消创建

#单据关联关系创建之后触发,表单服务策略执行之前
#如果需要干预下游单据,再执行表单服务策略,可以在此事件中对下游单据进行处理
#例如,要通过插件修改采购订单单价,之后系统自动执行表单服务策略计算金额
def OnAfterCreateLink(e):
    headEntity = e.Result.FindByEntityKey("FBillHead");#由于可能分单,所以可能有多个下游单据,是一个集合
    for entity in headEntity:#循环处理
        billObj=entity.DataEntity;#下游单据的完整数据包,可以通过实体数据包操作方式处理下游单据字段

#获取到服务策略之前触发,可在此事件中加入自定义的表单服务或者移除服务,用得少
def OnGetConvertBusinessService(e):
    FormSvcList=e.FormBusinessServices;#表单服务集合
    
#单据转换执行完成之后执行,在表单服务策略执行完成之后
#可以获取到下推完成之后,还未保存的下游单据,对下游单据数据包做最后的处理
#应用案例:单据转换插件提示消息
def AfterConvert(e):
    #整个单据转换执行完毕后的下游单据数据集合,相当于用户看到的推出来的下游单据,但是还没保存
    headEntity = e.Result.FindByEntityKey("FBillHead");#由于可能分单,所以可能有多个下游单据,是一个集合
    for entity in headEntity:#循环处理
        billObj=entity.DataEntity;


  • 相关应用案例参考推荐:

单据转换之多选基础资料下推携带

单据转换之多选辅助资料下推携带

单据转换插件提示消息

显示单据转换操作页面事件OnShowConvertOpForm

单据转换插件,动态表单插件,表单插件,服务插件,多单据体应用

案例四:AfterConvert事件

二开案例.单据转换插件.设置选单条件

单据转换实现多单据体到目标单的携带和关联

单据转换插件之携带子单据体

Python单据转换插件示例-自定义单据下推付款单

......

==========================本篇正文结束=====================================

截止到这一篇,Python插件开发各插件类型,已经介绍完了,感谢大家一如既往的关注与支持!

插件示例代码已经上传附件,老规矩,大家按需下载!

大家持续关注,点赞、评论、收藏,您的点赞、评论就是我前进的动力。

下一篇:【Python插件入门】第10篇(完结篇):插件常用工具类分享


Python单据转换插件示例.rar(3.12KB)


summary-icon摘要

由AI智能服务提供

本文是对“Python插件入门”系列中的一篇关于“简单账表服务插件”的扩展,主要介绍了账表表单插件的概念、应用场景、新建方法及常用事件。账表表单插件通过继承AbstractDynamicFormPlugIn基类实现,用于丰富报表界面功能,如设置数据颜色、修改报表单元格数据、单据关联查询等。文中提供了账表表单插件的详细开发步骤和代码示例,包括初始化、加载、数据绑定、按钮点击等事件的处理方法,展示了如何通过Python代码对报表进行自定义和扩展。

有用

反馈

往期回顾:

【Python插件入门】第1篇:Python插件入门讲解

【Python插件入门】第2篇:基本开发过程介绍

【Python插件入门】第3篇:插件中如何进行数据操作

【Python插件入门】第4篇:单据表单插件

【Python插件入门】第5篇:单据列表插件

【Python插件入门】第6篇:操作服务插件

【Python插件入门】第7篇:简单账表服务插件


    接上一篇,简单账表服务插件主要是完成了报表查询数据的构建,已经完成了简单账表查询功能的开发,那么对于查询界面在做一些"锦上添花"的功能,会让报表功能更加丰富,或者基于报表查询结果,再做一些单据联查、穿透查询等功能,可以通过账表表单插件来完成。

一、账表表单插件简介

    C#开发时,账表表单插件的基类是AbstractSysReportPlugIn与单据的表单插件基类不同,C#开始时要注意一下!Python插件开发的话,了解就行了,他也是AbstractDynamicFormPlugIn的派生类,和单据表单插件算是"兄弟"关系。账表表单插件是依赖于账表界面触发的,也属于界面类插件,既然都是继承于AbstractDynamicFormPlugIn,所以很多表单插件中的事件方法也是可以使用的。

    账表表单插件中的一些重要属性和成员这里就不单独介绍了,下面的常用事件讲解中会做介绍,继续往下看!

  • 账表表单插件常见应用场景

    ①账表查询界面设置数据颜色。

    ②修改报表单元格数据:转换百分数、千分号分隔符、修改底部合计结算逻辑等。

    ③单据关联查询。

    ④报表穿透查询,关联弹出其他账表。

    ⑤关闭表头排序功能

    ⑥报表表头字段赋值

    ⑦...

二、新建一个账表表单插件

  • 注意:这里提供一个引用比较全的账表表单插件示例模板,在附件中下载示例代码,复制到BOS里面注册!

  • Python账表表单插件注册方法:如下图所示,以上一篇测试示例报表为例,其他账表类似。

image.png


三、账表表单插件常用事件介绍

    这里做了表单插件的示例,对报表界面做了一些丰富,测试效果图如下:

    image.png

  • 下面看下账表表单插件中常用事件介绍:

#初始化
def OnInitialize(e):
    openPara=this.View.OpenParameter;#获取打开报表时传入的参数
    myPara=openPara.GetCustomParameter("参数标识");#根据参数标识获取某个打开参数
    if(myPara<>None):#若参数不为空
        myParaStr=("{0}").format(myPara);#将参数转换为字符串
        #将参数传递给过滤框,可在过滤框注册表单插件获取此参数
        param=SysReportShowParameter();
        param.CustomParams.Add("参数标识",myParaStr);
#账表页面加载完毕
def OnLoad(e):
    return;#这里示例不执行后面的代码
    #若报表没有显示表头字段,可通过如下代码隐藏表头部分,以免显示一块空白区域,影响用户体验
    sp=this.View.GetControl[SplitContainer]("FSpliteContainer");
    sp.HideFirstPanel(True);#折叠分割容器上面部分

#报表页面数据加载完毕
def AfterBindData(e):
    listGrid=this.View.GetControl("FList");
    listGrid.SetCustomPropertyValue("AllowSorting"False);#关闭表头的排序功能
    reportModel=this.Model;#SysReportModel类型,报表数据模型
    billObj=reportModel.DataObject;#报表页面实体数据包
    reportView=this.View;#SysReportView类型,报表界面View
    titles=reportModel.ReportTitles;#获取账表服务插件中构建的报表表头字段集
    msg="";
    for title in titles:
        fldKey=title.TitleKey;#表头字段标识
        fldValue=title.TitleValue;#表头字段值
        fldCtl=reportView.GetControl(title.TitleKey);#获取表头字段控件
        #fldCtl.Enabled=True;#默认是锁定的,可通过此代码放开表头字段,允许编辑
        #fldCtl.Visible=False;#隐藏表头字段
    customFilterObj=reportModel.FilterParameter.CustomFilter;#报表过滤框快捷过滤实体数据包
    orgObj=customFilterObj["F_BPW_OrgId"];#获取组织过滤字段
    orgId=orgObj["Id"if(orgObj<>Noneelse "0";#获取组织Id
    this.Model.SetItemValueByID("F_BPW_OrgId",orgId,0);#给表头组织字段赋值成基础资料
    #this.View.ShowMessage(msg);

#按钮点击事件
def ButtonClick(e):
    key=e.Key.ToUpperInvariant();#按钮标识大写
    if(key=="F_BPW_TestBtn".ToUpperInvariant()):
        this.Model.SetItemValueByID("F_BPW_OrgId",0,0);
        reportModel=this.Model;#SysReportModel类型,报表数据模型
        customFilterObj=reportModel.FilterParameter.CustomFilter;#报表过滤框快捷过滤实体数据包
        customFilterObj["F_BPW_OrgId"]=None;#清空过滤数据包中的组织字段
        customFilterObj["F_BPW_OrgId_Id"]=0;
        this.View.Refresh();#清空组织后刷新,获取所有组织数据
        
#表头菜单点击事件
def BarItemClick(e):
    key=e.BarItemKey.ToUpperInvariant();#菜单标识大写
    if(key=="F_BPW_MyBtn".ToUpperInvariant()):
        reportModel=this.Model;#SysReportModel类型,报表数据模型
        rowCount=reportModel.GetRowCount();#报表数据总行数
        tab=reportModel.DataSource;#账表数据源DataTable,报表所有行数据
        x=rowCount;
        mytab=reportModel.GetData(0,x);#获取报表0-x行数据,返回DataTable对象
        tempTabName=tab.TableName;#账表临时表名称,每一次查询报表都会不一样
        msg=("表头菜单{0}点击啦!").format(key);
        this.View.ShowMessage(msg);

#格式化行数据,每一行会运行一次 
#在此事件中可以设置报表数据的颜色
def OnFormatRowConditions(args):
    if(args.DataRow.ColumnContains("orgName")==True):#根据字段标识判断是否显示了该字段
        ss= ("{0}").format(args.DataRow["orgName"]);    #获取字段值,这里是取组织名称
        if("蓝海" in ss):#组织名称包含"蓝海"
            fc=FormatCondition();
            fc.BackColor="#ccffff";#背景色
            fc.ForeColor = "#FF0000";#前景色
            #fc.ColorField = "orgName";#颜色字段
            #fc.ApplayRow = False;#适用整行,默认值为True,设置ColorField时,才会单独对字段设置颜色,否则应用整行
            args.FormatConditions.Add(fc);
    reportModel=this.Model;
    tab=reportModel.DataSource;
    allFlds=tab.Columns;#报表所有字段
    #循环对动态列进行判断,数量不为0显示颜色
    for fld in allFlds:
        fldKey=fld.ColumnName;
        if("_" not in fldKey or fldKey.StartsWith("F")==False):#非数量动态列,不做处理
            continue;#自定义的动态列名规则,我示例报表动态数量列名是F开头且包含下划线,其他字段均不满足此条件
        if(args.DataRow.ColumnContains(fldKey)==True):
            qty= float(args.DataRow[fldKey]);
            if(qty<>0):
                fc=FormatCondition();
                fc.BackColor="#CCCCCC";#背景色
                fc.ForeColor ="#FF0000";#前景色
                fc.ColorField = fldKey;#颜色字段
                fc.ApplayRow = False;
                args.FormatConditions.Add(fc);

#去除尾0,自定义方法供参考
def remove_exponent(i):
    t=str(i);
    return t.rstrip('0').strip('.'if '.' in t else t;
#单元数据格式化
#在这个事件里面可以对报表原输出的单元格内容做修改
def FormatCellValue(args):
    fldkey=args.Header.Key;#字段标识
    if(fldkey=="F2018_1_4" ):
        dr=args.DataRow;#获取当前行的数据
        valueStr= ("{0}").format(args.FormateValue);#获取该字段原输出值
        args.FormateValue=("{0}%").format(round(float(valueStr)*100,2));#转换成百分数
    elif(fldkey=="orgName"):
        dr=args.DataRow;#获取当前行的数据
        valueStr= ("{0}").format(args.FormateValue);#获取该字段原输出值
        args.FormateValue=valueStr.Replace("蓝海","***");#将组织名称中的"蓝海"替换成***
    elif("_" in fldkey and fldkey.StartsWith("F")==True):
        args.FormateValue=remove_exponent(args.FormateValue);#去除尾0
        #oldValue=Decimal.Parse(args.FormateValue);#转换成Decimal
        #args.FormateValue =oldValue.ToString("N");#设置千分位分隔符
    cellTye=int(args.CellType);#单元格类型,0:普通,1:合计,2:小计
    if(cellTye==1 and fldkey=="F2018_1_6"):#判断底部合计行,可对底部合计行做自定义计算修改
        #这里演示将F2018_1_6底部合计修改成:F2018_1_4+F2018_1_5
        newSumValue=float(args.DataRow["F2018_1_4"])+float(args.DataRow["F2018_1_5"]);
        args.FormateValue=("{0}").format(newSumValue);

#单元格超链接点击事件
def EntryButtonCellClick(e):
    row=e.Row;#点击超链接所在序号,从1开始
    fldKey=e.FieldKey.ToUpperInvariant();#点击单元格字段标识大写
    msg=("点击了第{0}行的[{1}]").format(row,fldKey);
    this.View.ShowMessage(msg);
    #e.Cancel=True;#取消点击事件
    #获取当前单元格的值
    reportModel=this.Model;
    tab=reportModel.DataSource;
    value=("{0}").format(tab.Rows[row-1][e.FieldKey]);

#单元格双击事件
#简单账表 表单不会触发EntityRowDoubleClick事件,用此事件代替
def CellDbClick(Args):
    #Args.Cancel=True;#取消事件,若二开标准报表,可以此取消标准功能自带的双击事件
    row=Args.CellRowIndex;#双击序号,从1开始
    fldKey=Args.Header.FieldName;#双击单元格的字段名
    #获取当前单元格的值
    reportModel=this.Model;
    tab=reportModel.DataSource;
    value=("{0}").format(tab.Rows[row-1][fldKey]);#也可以传其他字段名,即可获取其他字段值
    msg=("点击了第{0}行的[{1}],{1}值={2}").format(row,fldKey,value);
    this.View.ShowMessage(msg);

#行点击事件
def EntityRowClick(e):
    row=e.Row;#点击的行序号,从1开始
    #msg=("点击了第{0}行!").format(row);
    #this.View.ShowMessage(msg);
    reportView=this.View;
    selectedDataRows=reportView.SelectedDataRows;#获取选中行,若启用了允许多选,则可获取到所有选中行
    if(selectedDataRows==None or selectedDataRows.Length==0):#无选中行数据
        return;
    #下面实现选中行合计功能
    dt=selectedDataRows[0].Table;
    allFlds=dt.Columns;#报表所有字段
    jsonArr=JSONArray();
    DIC={};
    DIC["F2018_1_4"]="2018-1-4";#添加需要合计的字段标识和字段标题的字典
    DIC["F2018_1_5"]="2018-1-5";
    for fldKey in DIC.keys():
        columnIndex=allFlds.IndexOf(fldKey);
        sumValue=0;
        for selectRow in selectedDataRows:
            fldValue=float(selectRow.ItemArray[columnIndex]);#取出选中行对应字段的值
            sumValue=sumValue+fldValue;
        jsonObj=JSONObject();
        jsonObj.Put("Value",remove_exponent(sumValue));
        jsonObj.Put("Caption",("{0}:").format(DIC[fldKey]));
        jsonArr.Add(jsonObj);
    listGrid=this.View.GetControl("FList");
    listGrid.SetSelectRowsTips(jsonArr);#输出到前端,按下alt键并把鼠标放到选中行的区域,即可看到效果

四、经典实践案例参考

    有了账表表单插件确实可以丰富很多功能,以下是整理的各位大佬分享的一些账表表单插件的常用案例。

    大多是C#插件示例,但是结合上面常用事件介绍中的Python示例,使用Python插件实现也不是难事。


==========================本篇正文结束=====================================

用2篇文章把Python账表开发的核心介绍完了,应该能满足95%以上的报表需求了。

示例代码已经上传附件,老规矩,大家按需下载!

感谢大家持续关注,点赞、评论、收藏,您的点赞、评论就是我前进的动力。


下一篇:【Python插件入门】第9篇-单据转换插件

简单账表-表单插件示例(全).rar(3.86KB)


summary-icon摘要由AI智能服务提供

本文简要介绍了如何使用Python脚本开发简单账表的取数服务插件。文章首先概述了简单账表的概念及其相对于直接SQL账表的优势,然后详细阐述了简单账表的基本开发步骤,包括新建简单账表、添加表头字段、开发取数服务插件等。接着介绍了账表服务插件的关键成员和主要事件,并通过示例代码展示了如何构建报表SQL和临时表。

有用反馈


往期回顾:

【Python插件入门】第1篇:Python插件入门讲解

【Python插件入门】第2篇:基本开发过程介绍

【Python插件入门】第3篇:插件中如何进行数据操作

【Python插件入门】第4篇:单据表单插件

【Python插件入门】第5篇:单据列表插件

【Python插件入门】第6篇:操作服务插件


    前面我们讲了服务端允许的操作服务插件,今天我们来了解一下简单账表的开发,简单账表需要通过取数服务插件来构建报表数据源,是简单账表开发的一个核心,而对于公有云发布.NET插件不方便,Python脚本支持开发简单账表服务插件,可以更加方便快速开发自定义报表。

    今天分享一下如何用Python脚本开发简单账表的取数服务插件。我测试过的最老的版本是V7.6.0.202103,比较老的版本可能会有问题,但是高于此版本的环境应该都是可以的。

一、简单账表简介

    账表分为直接SQL账表、简单账表、树形账表、分页账表,直接SQL账表其实已经可以实现我们的很多个性化报表需求,这种类型的报表开发核心只需要写SQL语句就可以快速完整报表的开发,但还是有一些局限性,例如,复杂的过滤条件、更灵活的数据源构建、动态列报表等。

    简单账表可以开发服务取数插件,来实现灵活的组装报表数据,非常实用,同时可以配合自定义的过滤界面来实现报表查询,在权限控制上面也会更加灵活。但相对直接SQL账表,开发步骤比较多,并需要编写插件代码,初学者掌握起来有点难度,但是我们必须突破这一关,掌握了简单账表的插件开发,在此基础上开发树形账表、分页账表也不是难事。

二、简单账表基本开发步骤

    在账表开发过程中,账表引擎只负责把账表服务端插件取到的数据绑定到设计好的账表模型上,账表开发基本步骤如下:

    ①新建->空白对象->简单账表。

    ②如果需要的话:添加表头字段。添加报表字段(通常都是在取数服务插件中构建,可以不做)

    ③新建过滤界面继承->BOS->应用框架->动态表单->公共过滤

    ④开发取数服务插件,看后面的Python脚本参考代码。核心的报表逻辑都在这一步。

    ⑤账表表单插件开发(可选):设置颜色、单元格数据格式等。(下一篇会对账表表单插件做介绍)

    ⑥权限配置,主控台菜单-账表发布。

 不过多介绍详细的开发步骤,社区已经有很多案例,推荐大家看看@拿了你的糖 老师的文章:账表开发超详细实现步骤

   我这里示例,已经在BOS建好相关的过滤框对象简单账表界面,大家参考上面的文章操作即可。

  • 简单账表Python服务插件注册如下图,可以将附件中的Python示例代码复制进去。
    (注意:与其他插件类型不同,简单账表服务插件,只能启用一个!)

    image.png


三、简单账表服务插件介绍

    账表服务插件是整个简单账表开发的核心过程,在插件中需要通过SQL组装,构建出最终账表数据的临时表,作为账表查询的最终数据源,账表引擎负责把临时表的数据显示到账表页面上,在账表服务插件中,还可以动态构建账表的列头、构建表头显示的字段、设置分组小计、合计等。

    在C#插件开发中,账表服务插件的基类是SysReportBaseService,账表服务插件与前面讲到的操作服务插件类似,也是一条"流水线作业",在不同的环节完成不同步骤,通常我们只需要实现几个关键的事件就可以开发出我们需要的个性化报表了,实现一次之后就可以按照这个模式开发各种报表了,这基本也能满足大部分的报表开发需求了。

    下面我们来看一下账表服务插件中的一些关键成员。

    账表服务插件中同样有this.Context,这个在前面的篇章中介绍了,用法类似,不再过多介绍。

  • this.ReportProperty:账表属性,包含账表开发过程中的全局信息。

    this.ReportProperty.ReportType;#账表类型,默认是简单账表,通常在初始化Initialize事件中对此属性赋值;
    #0: ReportType.REPORTTYPE_NORMAL 普通简单账表,1:ReportType.REPORTTYPE_TREE 树形账表,2:ReportType.REPORTTYPE_MOVE 分页账表

       this.ReportProperty.IsGroupSummary;#是否支持分组汇总
       this.ReportProperty.IsUIDesignerColumns;#账表列头是否是通过BOSIDE设计,默认False,

       #为True时,可在BOS中拖报表列字段(字段名与临时表字段名对应),而不在GetReportHeaders中构建

       this.ReportProperty.IdentityFieldName="FIDENTITYID";#可理解为临时表的主键,默认字段名是FIDENTITYID
       #FIDENTITYID字段是从1开始的整数序列,临时表中一定要有FIDENTITYID字段,

       #且必须从1开始、不能重复,不能跳号。否则报表页面会显示空白。

       this.ReportProperty.DecimalControlFieldList;#精度控制字段信息,预设数值字段的精度
       lstDcf=List[DecimalControlField]();
       dcf=DecimalControlField();
       dcf.ByDecimalControlFieldName="显示的字段名";
       dcf.DecimalControlFieldName = "用于控制精度的字段名";
       lstDcf.Add(dcf);
       this.ReportProperty.DecimalControlFieldList=lstDcf;

       this.ReportProperty.DspInsteadColumnsInfo;#列表格式化列,指示Key列被Value列内容替代
       this.ReportProperty.DspInsteadColumnsInfo.DefaultDspInsteadColumns.Add("列字段""显示字段");

       this.ReportProperty.GroupSummaryInfoData;#分组汇总信息

  • this.ReportTitles:账表表头字段信息,通常在GetReportTitles对表头字段进行传值

  • this.IsCreateTempTableByPlugin:报表是否调用BuilderReportSqlAndTempTable创建临时表,默认True

  • this.SummarySpecialFields:汇总字段信息

  • this.TempTableNameList:临时表列表

四、简单账表服务插件主要事件介绍

  • 账表服务插件通常实现7个关键事件

    初始化、临时表构造、构建报表列头、报表合计列(可选)、合计列计算逻辑(可选)、报表表头字段(可选)、报表关闭

    这里示例开发了一个简单的采购日报表,按照日期横向展示每天的订单数量,实现效果如下图:

image.png

image.png

  • 下面看下简单账表服务插件中主要的事件介绍:

#初始化,在此事件中设置报表的属性全局参数
def Initialize():
    this.ReportProperty.ReportType=ReportType.REPORTTYPE_NORMAL;
    this.IsCreateTempTableByPlugin=True;
    #是否支持分组汇总,在后面GetSummaryColumnInfo方法中添加汇总字段,
    #要在BOS中过滤框的汇总页签配置汇总信息,可参考:简单账表分组汇总设置
    this.ReportProperty.IsGroupSummary=True;
    #IsUIDesignerColumns=False,表示报表的列通过插件控制,后续在GetReportHeaders中构建列头
    #需要在BOS过滤框的显示隐藏列中维护,字段标识与临时表字段保持一致
    #账表列头构建更多详细说明参考:账表列构建
    this.ReportProperty.IsUIDesignerColumns=False;

#创建临时报表,正式进入账表取数sql拼接并取数,把账表取数结果放到创建的临时表中
#如果参数(this.IsCreateTempTableByPlugin=True),即调用BuilderReportSqlAndTempTable构建临时表
#否则调用以下3个接口,完成账表取数逻辑的sql指令即:BuilderSelectFieldSQL、BuilderTempTableOrderBySQL、BuilderFormWhereSQL
#rptfilter:账表参数,可以从这里获取过滤条件、排序字段、显示隐藏列等
#tableName:系统自动创建的账表临时表名,具备唯一性,最终报表页面展示的数据绑定此临时表,所以最终的报表结果数据要写入此临时表中
def BuilderReportSqlAndTempTable(rptfilter,tableName):
    #baseDataTemp=filter.BaseDataTempTable;#基础资料临时表;若设置了数据范围权限,该表会把根据数据范围过滤出来的内码存入临时表;
    #循环获取所有基础资料数据范围的数据,可用来拼接到报表SQL里面实现数据权限过滤
    #for b in baseDataTemp:
    #    baseType=b.BaseDataFormId;#基础资料FormId
    #    PKFldName=b.PKFieldName;#临时表中基础资料主键字段名,例如,FORGID
    #    baseTempTab=b.TempTable;#基础资料数据范围临时表名
    #filterStr=filter.FilterParameter.FilterString;#过滤框条件页签过滤表达式
    #过滤框快捷过滤页签的实体数据包,从这里面获取自定义的过滤字段值
    #DynamicObject类型,用前面讲的实体数据包操作方式取值,用绑定实体属性标识
    custFilter = rptfilter.FilterParameter.CustomFilter;
    if(custFilter==None):
        return;
    orgObj=custFilter["F_BPW_OrgId"];#获取组织
    whereOrgs="";
    if(orgObj<>None):
        orgId=("{0}").format(orgObj["Id"]);#组织ID
        whereOrgs=(" and a.FPURCHASEORGID in ({0}) ").format(orgId);#选择了组织,拼接组织过滤
    materials=custFilter["F_BPW_Materials"];#物料多选过滤
    matList=List[str]();
    if(materials<>None):
        for m in materials:
            materialNum="'"+str(m["F_BPW_Materials"]["Number"])+"'";#取出过滤框选择的多个物料编码
            matList.Add(materialNum);
    whereMat=(" and m.FNumber in ({0})").format(str.Join(",",matList)) if(matList.Count>0else "";#拼接物料多选过滤
    beginDate=str(custFilter["F_BPW_BeginDate"]);#获取开始日期
    EndDate=str(custFilter["F_BPW_EndDate"]);#获取结束日期
    beginDate=str(DateTime.Parse(("{0}").format(beginDate)).AddDays(-1));#开始日期的前一天
    itemDate=beginDate;
    fldsSql=List[str]();#动态列头SQL,相当于Select后面的部分字段是动态拼接的,以此来实现动态列
    #从开始日期起,循环加1天,一直到结束日期,过滤界面要控制录入的开始日期必须<结束日期,在过滤界面注册表单插件即可实现
    while(itemDate<>EndDate):
        myDate=DateTime.Parse(("{0}").format(itemDate));
        fldKey=("F{0}_{1}_{2}").format(myDate.Year,myDate.Month,myDate.Day);#根据每个日期构建唯一的列名
        ss=("{0}=SUM(Case when a.FDATE='{1}' then b.FQty else 0 end)").format(fldKey,myDate);#构建每天订单数量合计SQL
        fldsSql.Add(ss);#将SQL添加到动态列SQL集合中备用
        nextDate=myDate.AddDays(1);
        itemDate=str(nextDate);#迭代+1天
    myDate=DateTime.Parse(("{0}").format(itemDate));#这里是选择的截止日期,由于循环到最后一天跳出了,这里补充一天的数据
    fldKey=("F{0}_{1}_{2}").format(myDate.Year,myDate.Month,myDate.Day);
    ss=("{0}=SUM(Case when a.FDATE='{1}' then b.FQty else 0 end)").format(fldKey,myDate);
    fldsSql.Add(ss);#将SQL添加到动态列SQL集合中备用
    #raise Exception(str.Join(',',fldsSql));#调试时,可用此行代码,看看构建的动态列SQL对不对
    #组装最终写入报表临时表的SQL
    #注意!!!: 最终临时表一定要有FIDENTITYID ,要从1开始,且不重复 ,不断号,不然前台显示空白!!!!
    sql=("""/*dialect*/ select row_Number() Over(order by a.orgName,a.MaterialNum)  FIDENTITYID, a.* 
into {0} 
from 
(select  orgL.FNAME orgName,m.FNUMBER MaterialNum,mL.FNAME materialName,mL.FSPECIFICATION SpecNum,unitL.FNAME unit,
{1}
from t_PUR_POOrder a
inner join T_ORG_ORGANIZATIONS org on org.FORGID=a.FPURCHASEORGID
inner join T_ORG_ORGANIZATIONS_L orgL on orgL.FORGID=org.FORGID and orgL.FLOCALEID=2052
inner join t_PUR_POOrderEntry b on a.FID=b.FID
inner join T_BD_MATERIAL m on m.FMATERIALID=b.FMATERIALID
inner join T_BD_MATERIAL_L mL on mL.FMATERIALID=m.FMATERIALID and mL.FLOCALEID=2052
inner Join T_BD_UNIT_L unitL on unitL.FUNITID=b.FUNITID and  unitL.FLOCALEID=2052
where a.FDATE>='{3}' and a.FDATE<='{4}' {2} {5}
group by orgL.FNAME,m.FNUMBER,mL.FNAME,mL.FSPECIFICATION,unitL.FNAME
) a 
  """).format(tableName,str.Join(',',fldsSql),whereOrgs,beginDate,EndDate,whereMat);
      #raise Exception(sql);#可以通过此方法弹出Sql语句进行调试验证
    DBUtils.Execute(this.Context,sql);#执行SQL,将报表数据写入临时表

#构建账表列头
def GetReportHeaders(Filter):
    header=ReportHeader();
    localEid=this.Context.UserLocale.LCID;#获取当前语言环境代码,中文为2052
    header.AddChild("orgName",LocaleValue("采购组织",localEid));#字段名,列头标题,字段名与临时表中的字段名保持对应,相当于每一个列头对应临时表的哪个字段
    header.AddChild("MaterialNum",LocaleValue("物料编码",localEid));
    header.AddChild("materialName",LocaleValue("物料名称",localEid));
    header.AddChild("SpecNum",LocaleValue("规格型号",localEid));
    header.AddChild("unit",LocaleValue("采购单位",localEid));
    
    #下面根据过滤条件选择的日期区间,动态构建列头,和上面构建SQL字段的逻辑类似
    custFilter = Filter.FilterParameter.CustomFilter;
    beginDate=str(custFilter["F_BPW_BeginDate"]);#获取开始日期
    EndDate=str(custFilter["F_BPW_EndDate"]);#获取结束日期
    itemDate=beginDate;
    fldsSql=List[str]();
    while(itemDate<>EndDate):
        myDate=DateTime.Parse(("{0}").format(itemDate));
        fldKey=("F{0}_{1}_{2}").format(myDate.Year,myDate.Month,myDate.Day);#这里的字段名要和前面构建的SQL字段对应
        header.AddChild(fldKey,LocaleValue(str(("{0}-{1}-{2}").format(myDate.Year,myDate.Month,myDate.Day)),localEid),SqlStorageType.SqlDecimal);
        nextDate=myDate.AddDays(1);
        itemDate=str(nextDate);
    myDate=DateTime.Parse(("{0}").format(itemDate));
    fldKey=("F{0}_{1}_{2}").format(myDate.Year,myDate.Month,myDate.Day);#这里的字段名要和前面构建的SQL字段对应
    header.AddChild(fldKey,LocaleValue(str(("{0}-{1}-{2}").format(myDate.Year,myDate.Month,myDate.Day)),localEid),SqlStorageType.SqlDecimal);
#设置列的索引,使其可以按照以上列头构建的顺序显示
    colIndex=0;
    for child in header.GetChilds():
        if(child.GetChildCount()==0):
            child.ColIndex=colIndex;
            colIndex=colIndex+1;
        else:
            child.ColIndex = colIndex;
            colIndex=colIndex+1;
            for childHeader in child.GetChilds():
                childHeader.ColIndex=colIndex;
                colIndex=colIndex+1;
    return header;

#设置报表表头字段值
#这里主要是把过滤框设置的字段值,显示到报表表头
#注意:这里只能以文本的形式传递到字段上,不能传递基础资料
#若要在表头按照基础资料的形式展示,可以参考:简单账表表单插件中启用表单服务插件中定义的Ttile
def GetReportTitles(Filter):
    reportTitles=ReportTitles();
    custFilter=Filter.FilterParameter.CustomFilter;#获取过滤框的数据包
    orgObj=custFilter["F_BPW_OrgId"];#获取组织
    beginDate=str(custFilter["F_BPW_BeginDate"]);#获取开始日期
    EndDate=str(custFilter["F_BPW_EndDate"]);#获取结束日期
    if(orgObj<>None):
        reportTitles.AddTitle("F_BPW_OrgId", orgObj["Name"]);
    reportTitles.AddTitle("F_BPW_BeginDate", beginDate);
    reportTitles.AddTitle("F_BPW_EndDate", EndDate);
    return reportTitles;

#设置报表底部合计列
def GetSummaryColumnInfo(rptfilter):
    result=List[SummaryField]();
    #由于这里的数量字段是动态构建的,所以也需要动态添加合计列,字段名与前面保持一致
    custFilter = rptfilter.FilterParameter.CustomFilter;
    beginDate=str(custFilter["F_BPW_BeginDate"]);#获取开始日期
    EndDate=str(custFilter["F_BPW_EndDate"]);#获取结束日期
    itemDate=beginDate;
    fldsSql=List[str]();
    while(itemDate<>EndDate):
        myDate=DateTime.Parse(("{0}").format(itemDate));
        fldKey=("F{0}_{1}_{2}").format(myDate.Year,myDate.Month,myDate.Day);#这里的字段名要和前面构建的SQL字段对应
        result.Add(SummaryField(fldKey,BOSEnums.Enu_SummaryType.SUM));
        nextDate=myDate.AddDays(1);
        itemDate=str(nextDate);
    myDate=DateTime.Parse(("{0}").format(itemDate));
    fldKey=("F{0}_{1}_{2}").format(myDate.Year,myDate.Month,myDate.Day);
    result.Add(SummaryField(fldKey,BOSEnums.Enu_SummaryType.SUM));
    return result;

#汇总字段计算逻辑,如果不是单纯的sum(xxx),可以在此方法中处理,本示例未作处理

!!!这个GetSummaryColumsSQL事件返回的内容,最终会拼接到 Select后面,不能return ""

!!!注意:此方法,仅支持2024.11.21及后面的版本

以前的版本,Python插件不会触发GetSummaryColumsSQL,所以一直没报错,以前按照这篇文章编写的代码可能会引发如下图的报错:

相关报错问答:升级至9.0.0.20250109版本后,python脚本二开简单账表都出来异常

image.png

如果不需要干预合并逻辑,那个不要使用这个事件,把该事件的代码删除,即可解决此报错!

如果需要使用这个事件可以参考这个:简单账表Python插件支持GetSummaryColumsSQL方法

#def GetSummaryColumsSQL(summaryFields):

#报表关闭触发,通常在此处清理报表过程产生的临时表
def CloseReport():
    this.DropTempTable();


==========================本篇正文结束=====================================

简单账表服务插件的核心开发过程介绍完了,示例代码已经上传附件,老规矩,大家按需下载!

感谢大家持续关注,点赞、评论、收藏,您的点赞、评论就是我前进的动力。

简单账表服务插件主要是完成了报表查询数据的构建,对查询界面有一些个性化的设置还可以通过账表表单插件来处理。

下一篇:【Python插件入门】第8篇-账表表单插件

简单账表(采购日报表)取数服务插件示例.rar(4.33KB)


Go-Micro、Go-Zero 和 GoFrame 是 Go 语言生态中三个主流的微服务开发框架,它们各自针对不同的场景和需求设计,具有不同的优缺点。以下是对它们的详细对比分析:

1. Go-Micro

定位:老牌通用微服务框架,提供服务发现、RPC、事件总线等基础组件。

优点

  • 组件化设计:提供服务发现、负载均衡、编码解码、请求重试等微服务核心组件,支持插件化扩展(如 Consul、Etcd 等注册中心)。

  • 多协议支持:原生支持 HTTP、gRPC、MQTT 等多种通信协议,灵活适配不同场景。

  • 社区成熟:作为早期微服务框架,社区资源丰富,文档完善,适合快速上手。

  • 云原生友好:支持 Kubernetes、Docker 等容器化部署,与云原生生态兼容。

缺点

  • 学习曲线陡峭:组件较多,初学者需要理解服务发现、编解码等概念,上手难度较高。

  • 配置复杂:需要手动配置多个组件,如注册中心、传输协议等,对新手不友好。

  • 性能一般:相比专注性能优化的框架(如 Go-Zero),RPC 调用延迟略高。

适用场景

  • 跨语言微服务架构(支持多种协议)。

  • 需要灵活扩展组件的场景。

  • 对服务治理有较高要求的企业级应用。

2. Go-Zero

定位:高性能微服务框架,专注于 API 网关和 RPC 服务。

优点

  • 极致性能:基于 fasthttp(比 net/http 快 3 倍以上),适合高并发场景,官方宣称 QPS 比同类框架高 50%。

  • 代码生成工具:提供 goctl 工具自动生成 API 和 RPC 代码,减少重复工作。

  • 集成熔断限流:内置断路器、限流、负载均衡等中间件,提升系统稳定性。

  • 简化开发:通过模板和代码生成,降低微服务开发门槛,适合快速迭代。

缺点

  • 生态较封闭:依赖官方工具链(如 goctl),自定义扩展需了解框架内部实现。

  • 文档不够完善:相比 Go-Micro 和 GoFrame,文档细节和示例较少。

  • 组件扩展性弱:部分组件(如注册中心)仅支持有限选项(如 Etcd),插件化能力不足。

适用场景

  • 高并发、低延迟的互联网应用(如电商、社交)。

  • 需要快速迭代的项目(代码生成工具提升效率)。

  • 对性能敏感的后端服务(如网关、API 服务)。

3. GoFrame

定位:全功能 Web 开发框架,提供完整的工具链。

优点

  • 一站式解决方案:内置 ORM、缓存、日志、配置、CLI 等模块,无需额外依赖。

  • 文档详尽:官方文档覆盖全面,示例丰富,适合新手快速上手。

  • 统一风格:所有组件遵循一致的设计风格,降低学习成本。

  • 企业级支持:提供事务管理、分布式锁、限流等企业级功能。

缺点

  • 框架较重:集成组件较多,适合单体应用或全栈开发,微服务场景可能冗余。

  • 性能略低:相比 Go-Zero,基于标准库实现,性能不是首要优化目标。

  • 社区活跃度低:相比 Go-Micro 和 Gin,社区规模较小,第三方插件较少。

适用场景

  • 单体应用或全栈开发(无需频繁切换工具)。

  • 对开发效率要求高的项目(一站式解决方案)。

  • 企业内部系统(需要事务、权限等功能)。

对比总结

维度Go-MicroGo-ZeroGoFrame
性能中等极高(基于 fasthttp)中等(基于标准库)
学习成本高(组件多)中(依赖代码生成)低(一站式设计)
生态成熟度高(早期框架)中(快速发展)中(国内用户较多)
扩展性强(插件化设计)中(官方组件为主)中(内置组件较多)
适用场景跨语言、复杂微服务架构高并发、快速迭代项目单体应用、全栈开发

如何选择?

  • 优先选 Go-Micro:如果需要跨语言调用、灵活的组件扩展,或已有成熟的微服务生态。

  • 优先选 Go-Zero:如果项目对性能要求极高,且团队接受代码生成工具(如 goctl)。

  • 优先选 GoFrame:如果需要一站式解决方案,或开发单体应用、企业内部系统。


建议根据项目需求、团队技术栈和性能要求综合评估,也可以组合使用(如用 Go-Zero 开发高性能服务,用 GoFrame 开发管理后台)。


本文由GSYTech 恋猫de小郭分享,原题“2025 跨平台框架更新和发布对比,这是你没看过的全新版本”,下文有修订和重新排版。

1、前言

2025 年可以说又是一个跨平台的元年,其中不妨有鸿蒙Next平台刺激的原因,也有大厂技术积累“达到瓶颈”的可能,又或者“开猿截流、降本增笑”的趋势的影响,2025 年上半年确实让跨平台框架又成为最活跃的时刻。

例如:

  • 1)Flutter Platform 和 UI 线程合并和Android Impeller 稳定;

  • 2)React Native 优化 Skia 和发布全新 WebGPU 支持;

  • 3)Compose Multiplatform iOS 稳定版发布,客户端全平台稳定;

  • 4)腾讯 Kotlin 跨平台框架 Kuikly 正式开源;

  • 5)字节跨平台框架 Lynx 正式开源;

  • 6)uni-app x 跨平台框架正式支持鸿蒙;

  • ····

本篇基于当前各大活跃的跨端框架的现状,对比当前它们的情况和未来的可能,帮助你在选择框架时更好理解它们的特点和差异。

2、系列文章

本文是系列文章中的第 14篇,本系列总目录如下:

IM跨平台技术学习(一):快速了解新一代跨平台桌面技术——Electron

IM跨平台技术学习(二):Electron初体验(快速开始、跨进程通信、打包、踩坑等)

IM跨平台技术学习(三):vivo的Electron技术栈选型、全方位实践总结

IM跨平台技术学习(四):蘑菇街基于Electron开发IM客户端的技术实践

IM跨平台技术学习(五):融云基于Electron的IM跨平台SDK改造实践总结

IM跨平台技术学习(六):网易云信基于Electron的IM消息全文检索技术实践

IM跨平台技术学习(七):得物基于Electron开发客服IM桌面端的技术实践

IM跨平台技术学习(八):新QQ桌面版为何选择Electron作为跨端框架

IM跨平台技术学习(九):全面解密新QQ桌面版的Electron内存占用优化

IM跨平台技术学习(十):快速选型跨平台框架Electron、Flutter、Tauri、React Native等

IM跨平台技术学习(十一):环信基于Electron打包WebIM桌面端的技术实践

IM跨平台技术学习(十二):万字长文详解QQ Linux端实时音视频背后的跨平台实践

IM跨平台技术学习(十三):从理论到实践,详细对比Electron和Tauri的优劣

IM跨平台技术学习(十四):鸿蒙NEXT时代你所不知道的全平台跨端框架》(☜ 本文)

3、Flutter

首先 Flutter 大家应该已经很熟悉了,作为在「自绘领域」坚持了这么多年的跨平台框架,相信也不需要再过多的介绍,因为是「自绘」和 「AOT 模式」,让 Flutter 在「平台统一性」和「性能」上都有不错的表现。开发过程过程中的 hotload 的支持程度也很不错。

而自 2025 以来的一些更新也给 Flutter 带来了新的可能,比如 Flutter Platform 和 UI 线程合并 ,简单来说就是以前 Dart main Thread 和 Platform UI Thread 是分别跑在独立线程,它们的就交互和数据都需要经过 Channel 。

而合并之后:Dart main 和 Platform UI 在 Engine 启动完成后会合并到一个线程,此时 Dart 和平台原生语言就支持通过同步的方式去进行调用,也为 Dart 和 Kotlin/Java,Swift/OC 直接同步互操作在 Framework 提供了进一步基础支持。

当然也带来一些新的问题,具体可见线程合并的相关文章。

另外在当下:其实 Flutter 的核心竞争力是 Impeller,因为跨平台框架不是系统“亲儿子”,又是自绘方案,那么在性能优化上,特别 iOS 平台,就不得不提到着色器预热或者提前编译。

传统 Skia 需要把「绘制命令」编译成可在 GPU 执行代码的过程,一般叫做着色器编译, Skia 需要「动态编译」着色器,但是 Skia 的着色器「生成/编译」与「帧工作」是按顺序处理,如果这时候着色器编译速度不够快,就可能会出现掉帧(Jank)的情况,这个我们也常叫做「着色器卡顿」。

而 Impeller 正是这个背景的产物,简单说,App 所需的所有着色器都在 Flutter 引擎构建时进行离线编译,而不是在应用运行时编译。

这其实才是目前是 Flutter 的核心竞争力,不同于 Skia 需要考虑多场景和平台通用性,需要支持各种灵活的额着色器场景,Impeller 专注于 Flutter ,所以它可以提供更好的专注支持和问题修复。

当然 Skia 也是 Google 项目,对于着色器场景也有 Graphite 后端在推进支持,它也在内部也是基于 Impeller 为原型去做的改进,所以未来 Skia 也可以支持部分场景的提前编译。

而在鸿蒙平台:华为针对 Flutter 在鸿蒙的适配,在华为官方过去的分享里,也支持了《Flutter引擎Impeller鸿蒙化》。

甚至,Flutter 在类游戏场景支持也挺不错,如果配合 rive 的状态机和自适应,甚至可以开发出很多出乎意料的效果,而官方也有 Flutter 的游戏 SDK 或者 Flame 第三方游戏包支持(如下图所示)。

最后,那么 Flutter 的局限性是什么呢?其实也挺多的,例如:

  • 1)文字排版能力不如原生;

  • 2)PC平台推进交给了 Canonical 团队负责,虽然有多窗口雏形,但是推进慢;

  • 3)不支持官方热更新,shorebird 国内稳定性一般;

  • 4)内存占用基本最高;

  • 5)Web 只支持 wasm 路线;

  • 6)鸿蒙版本落后主版本太多;

  • 7)不支持小程序,虽然有第三方实现,但是力度不大;

  • 8)····。

所以,Flutter 适合你的场景吗?

4、React Native

如果你很久没了解过 RN ,那么 2025 年的 RN 会超乎你的想象,可以说 Skia 和 WebGPU 给了它更多的可能。

动图封面

RN 的核心之一就是对齐 Web 开发体验,其中最重要的就是 0.76 之后 New Architecture 成了默认框架,例如 Fabric, TurboModules, JSI 等能力解决了各种历史遗留的性能瓶颈。

比如:

  • 1)JSI 让 RN 可以切换 JS 引擎,比如 Chakra、v8、Hermes,同时允许 JS 和 Native 线程之间的同步相互执行;

  • 2)全新的 Fabric 取代了原本的 UI Manager,支持 React 的并发渲染能力,特别是现在的新架构支持 React 18 及更高版本中提供的并发渲染功能,对齐 React 最新版本,比如 Suspense & Transitions;

  • 3)Hermes JS 引擎预编译的优化字节码,优化 GC 实现等

  • 4)TurboModules 按需加载插件

  • 5)····

另外现在新版 RN 也支持热重载,同时可以更快对齐新 React 特性,例如 React 19 的 Actions、改进的异步处理等 。

而另一个支持就是 RN 在 Skia 和 WebGPU 的探索和支持,使用 Skia 和 WebGPU 不是说 RN 想要变成自绘,而是在比如「动画」和「图像处理」等场景增加了强力补充。

还有是 React Native 开始引入 WebGPU 支持,其效果将确保与 Web 端的 WebGPU API 完全一致,允许开发者直接复制代码示例的同时,实现与 Web Canvas API 对称的 RN Canvas API。

最后,WebGPU 的引入还可以让 React Native 开发者能够利用 ThreeJS 生态,直接引入已有的 3D 库,这让 React Native 的能力进一步对齐了 Web。

最后,RN 也是有华为推进的鸿蒙适配,会采用 XComponent 对接到 ArkUI 的后端接口进行渲染,详细可见《鸿蒙版 React Native 正式开源》。

而在 PC 领域 RN 也有一定支持,比如微软提供的 windows 和 macOS 支持,社区提供的 web 和 Linux 支持,只是占有并不高,一般忽略。

而在小程序领域,有京东的 Taro 这样的大厂开源支持,整体在平台兼容上还算不错。

当然,RN 最大的优势还在于成熟的 code-push 热更新支持。

那么使用 RN 有什么局限性呢?

最直观的肯定是平台 UI 的一致性和样式约束,这个是 OEM 框架的场景局限,而对于其他的,目前存在:

1)第三方库在新旧框架支持上的风险

2)RN 版本升级风险,这个相信大家深有体会

3)平台 API 兼容复杂度较高

4)0.77 之后才支持 Google Play 的 16 KB 要求

5)可用性集中在 Android 和 iOS ,鸿蒙适配和维度成本更高

6)小程序能力支持和客户端存在一定割裂

7)····

事实上, RN 是 Cordova 之后我接触的第一个真正意义上的跨平台框架,从我知道它到现在应该有十年了,那么你会因为它的新架构和 WebGPU 能力而选择 RN 么?

5、Compose Multiplatform

Compose Multiplatform(CMP) 近期的热度应该来自 Compose Multiplatform iOS 稳定版发布 ,作为第二个使用 Skia 的自绘框架,除了 Web 还在推进之外, CMP 基本完成了它的跨平台稳定之路。

Compose Multiplatform(CMP) 是 UI,Kotlin Multiplatform (KMP) 是语言基础。

CMP 使用 Skia 绘制 UI ,甚至在 Android 上它和传统 View 体系的 UI 也不在一个渲染树,并且 CMP 通过 Skiko (Skia for Kotlin) 这套 Kotlin 绑定库,进而抹平了不同架构(Kotlin Native,Kotlin JVM ,Kotlin JS,Kotlin wasm)调用 skia 的差异。

所以 CMP 的优势也来自于此,它可以通过 skia 做到不同平台的 UI 一致性,并且在 Android 依赖于系统 skia ,所以它的 apk 体积也相对较小,而在 PC 平台得益于 JVM 的成熟度,CMP 目前也做到了一定的可用程度。

其中和 Android JVM 模式不同的是,Kotlin 在 iOS 平台使用的是 Kotlin/Native,Kotlin/Native 是 KMP 在 iOS 支持的关键能力,它负责将 Kotlin 代码直接编译为目标平台的机器码或 LLVM 中间表示 (IR),最终为 iOS 生成一个标准 .framework,这也是为什么 Compose iOS 能实现接近原生的性能。

实现鸿蒙支持目前主流方式也是 Kotlin/Native ,不得不说 Kotlin 最强大的核心价值不是它的语法糖,而是它的编译器,当然也有使用 Kotlin/JS 适配鸿蒙的方案。

所以 CMP 最大的优势其实是 Kotlin,Kotlin 的编译器很强大,支持各种编译过程和产物,可以让 KMP 能够灵活适配到各种平台,并且 Kotlin 语法的优势也让使用它的开发者忠诚度很高。

不过遗憾的是,目前 CMP 鸿蒙平台的适配上都不是 Jetbrains 提供的方案,华为暂时也没有 CMP 的适配计划,目前已知的 CMP/KMP 适配基本是大厂自己倒腾的方案,有基于 KN 的 llvm 方案,也有基于 Kotlin/JS 的低成本方案,只是大家的路线也各不相同。在小程序领域同样如此。

另外现在 CMP 开发模式下的 hot reload 已经可以使用,不过暂时只支持 desktop,原理大概是只支持 jvm 模式。

而在社区上,klibs.io 的发布也补全了 Compose Multiplatform 在跨平台最后一步。

这也是 Compose iOS 能正式发布的另外一个原因:

那么聊到这里,CMP 面临的局限性也很明显:

  • 1)鸿蒙适配成本略高,没有官方支持,低成本可能会选择 Kotlin/JS,为了性能的高成本可能会考虑 KN,但是 KN 在 iOS 和鸿蒙的 llvm 版本同步适配也是一个需要衡量的成本;

  • 2)小程序领域需要第三方支持;

  • 3)iOS 平台可能面临的着色器等问题暂无方案,也许未来等待 Skia 的 Graphite 后端;

  • 4)在 Android JVM 模式和 iOS 的 KN 模式下,第三方包适配的难度略高;

  • 5)hotload 暂时只支持 PC;

  • 6)桌面内存占用问题;

  • 7)没有官方热更新条件;

  • 8)kjs、kn、kjvm、jwasm 之间的第三方包兼容问题;

  • 9)····

相信 2025 年开始,CMP 会是 Android 原生开发者在跨平台的首选之一,毕竟 Kotlin 生态不需要额外学习 Dart 或者 JS 体系,那么你会选择 CMP 吗?

6、Kuikly

Kuikly 其实也算是 KMP 体系的跨平台框架,只是腾讯在做它的时候还没 CMP ,所以一开始 Kuikly 是通过 KMM 进行实现,而后在 UI 层通过自己的方案完成跨平台。

这其实就是 Kuikly 和 CMP 最大的不同,底层都是 KMP 方案,但是在绘制上 Kuikly 采用的是类 RN 的方式,目前 Kuikly 主要是在 KMP 的基础上实现的自研 DSL 来构建 UI ,比如 iOS 平台的 UI 能力就是 UIkit,而大家更熟悉的 Compose 支持,

目前还处于开发过程中:

SwiftUI 和 Compose 无法直接和 Kuikly 一起使用,但是 Kuikly 可以在 DSL 语法和 UI 组件属性对齐两者的写法,变成一个类 Compose 和 SwiftUI 的 UI 框架,也就是 Compose DSL 大概就是让 Kuikly 更像 Compose ,而不是直接适配 Compose。

那么,Kuikly 和 RN 之间又什么区别?

  • 1)Kuikly 支持 Kotlin/JS 和 Kotlin/Native 两种模式,也就是它可以支持性能很高的 Native 模式;

  • 2)Kuikly 实现了自己的一套「薄原生层」,Kuikly 使用“非常薄”的原生层,该原生层只暴露最基本和无逻辑的 UI 组件(原子组件),也就是 Kuikly 在 UI 上只用了最基本的原生层 UI ,真正的 UI 逻辑主要在共享的 Kotlin 代码来实现:

通过将 UI 逻辑抽象到共享的 Kotlin 层,减少平台特定 UI 差异或行为差异的可能性,「薄原生层」充当一致的渲染目标,确保 Kotlin 定义的 UI 元素在所有平台上都以类似的方式显示。

也就是说,Kuikly 虽然会依赖原生平台的控件,但是大部分控件的实现都已经被「提升」到 Kuikly 自己的 Kotlin 共享层,目前 Kuikly 实现了 60% UI 组件的纯 Kotlin 组合封装实现,不需要 Native 提供原子控件。

另外 Kuikly 表示后续会支持全平台小程序,这也是优势之一。

最后,Kuikly 还在动态化热更新场景, 可以和自己腾讯的热更新管理平台无缝集成,这也是优势之一。

那么 Kuikly 存在什么局限性?

首先就是动态化场景只支持 Kotlin/JS,而可动态化类型部分:

  • 1)不可直接依赖平台能力;

  • 2)不可使用多线程和协程;

  • 3)不可依赖内置部分。

其他的还有:

  • 1)UI 不是 CMP ,使用的是类 RN 方式,所谓需要稍微额外理解成本;

  • 2)不支持 PC 平台;

  • 3)基于原生 OEM,虽然有原子控件,但是还是存在部分不一致情况;

  • 4)在原有 App 集成 Kuikly ,只能把它简单当作如系统 webview 的概念来使用。

另外,腾讯还有另外一个基于 CMP 切适配鸿蒙的跨平台框架,只是何时开源还尚不明确。

那么,你会为了小程序和鸿蒙NEXT而选择 Kuikly 吗?

7、Lynx

如果说 Kuikly 是一个面向客户端的全平台框架,那么 Lynx 就是一个完全面向 Web 前端的跨平台全家桶。

目前 Lynx 开源的首个支持框架就是基于 React 的 ReactLynx,当然官方也表示Lynx 并不局限于 React,所以不排除后续还有 VueLynx 等其他框架支持,而 Lynx 作为核心引擎支持,其实并不绑定任何特定前端框架,只是当前你能用的暂时只有 ReactLynx。

而在实现上,源代码中的标签,会在运行时被 Lynx 引擎解析,翻译成用于渲染的 Element,嵌套的 Element 会组成的一棵树,从而构建出UI界面。

所以从这里看,初步开源的 Lynx 是一个类 RN 框架,不过从官方的介绍“选择在移动和桌面端达到像素级一致的自渲染” ,可以看出来宣传中可以切换到自渲染,虽然暂时还没看到。

而对于 Lynx 主要的技术特点在于:

1)「双线程架构」,思路类似 react-native-reanimated ,JavaScript 代码会在「主线程」和「后台线程」两个线程上同时运行,并且两个线程使用了不同的 JavaScript 引擎作为其运行时:

2)另外特点就是 PrimJS,一个基于 QuickJS 深度定制和优化的 JavaScript 引擎,主要有模板解释器(利用栈缓存和寄存器优化)、与 Lynx 对象模型高效集成的对象模型(减少数据通信开销)、垃圾回收机制(非 QuickJS 的引用计数 RC,以提升性能和内存分析能力)、完整实现了 Chrome DevTools Protocol (CDP) 以支持 Chrome 调试器等;

3)“Embedder API” 支持直接与原生 API 交互 ,提供多平台支持。

所以从 Lynx 的宏观目标来看,它即支持类 RN 实现,又有自绘计划,同时除了 React 模式,后期还适配 Vue、Svelte 等框架,可以说是完全针对 Web 开发而存在的跨平台架构。

另外支持平台也足够,Android、iOS、鸿蒙、Web、PC、小程序都在支持列表里。

最后,Lynx 对“即时首帧渲染 (IFR)”和“丝滑流畅”交互体验有先天优势,开发双线程模型及主线程脚本 (MTS) 让 Lynx 的启动和第一帧渲染速度还挺不错。

比如:

  • 1)Lynx 主线程负责处理直接处理屏幕像素渲染的任务,包括:执行主线程脚本、处理布局和渲染图形等等,比如负责渲染初始界面和应用后续的 UI 更新,让用户能尽快看到第一屏内容;

  • 2)Lynx 的后台线程会运行完整的 React 运行时,处理的任务不直接影响屏幕像素的显示,包括在后台运行的脚本和任务(生命周期和其他副作用),它们与主线程分开运行,这样可以让主线程专注于处理用户交互和渲染,从而提升整体性能。

而在多平台上,Lynx 是自主开发的渲染后端支持 Windows、tvOS、MacOS 和 HarmonyOS ,但是不确实是否支持 Linux。

那 Lynx 有什么局限性?

首先:肯定是它非常年轻,虽然它的饼很大,但是对应社区、生态系统、第三方库等都还需要时间成长。

所以官方也建议 Lynx 最初可能更适合作为模块嵌入到现有的原生应用中,用于构建特定视图或功能,而非从零开始构建一个完整的独立应用。

其次:就是对 Web 前端开发友好,对客户端而言学习成本较高,并且按照目前的开源情况,除了 Android、iOS 和 Web 的类 RN 实现外,其他平台的支持和自绘能力尚不明确:

最后:Lynx 的开发环境最好选 macOS,关于 Windows 和 Linux 平台目前工具链兼容性还需要打磨。

总结下来,Lynx 应该会是前端开发的菜,那你觉得 Lynx 是你的选择么?

8、uni-app x

说到 uni-app 大家第一印象肯定还是小程序,而虽然 uni-app 也可以打包客户端 app,甚至有基于 weex 的 nvue 支持,但是其效果只能说是“一言难尽”,而这里要聊的 uni-app x ,其实就是 DCloud 在跨平台这两年的新尝试。

具体来说:就是 uni-app 不再是运行在 jscore 的跨平台框架,它是“基于 Web 技术栈开发,运行时编译为原生代码”的模式,相信这种模式大家应该也不陌生了,简单说就是js(uts) 代码在打包时会直接编译成原生代码。

甚至极端一点说:uni-app x 可以不需要单独写插件去调用平台 API,你可以直接在 uts 代码里引用平台原生 API ,因为你的代码本质上也是会被编译成原生代码,所以 uts ≈ native code ,只是使用时需要配置上对应的条件编译(如 APP-ANDROID、APP-IOS)支持。

import Context from"android.content.Context";
import BatteryManager from"android.os.BatteryManager";

import { GetBatteryInfo, GetBatteryInfoOptions, GetBatteryInfoSuccess, GetBatteryInfoResult, GetBatteryInfoSync } from'../interface.uts'
import IntentFilter from'android.content.IntentFilter';
import Intent from'android.content.Intent';

import { GetBatteryInfoFailImpl } from'../unierror';

/**
* 获取电量
*/
exportconst getBatteryInfo : GetBatteryInfo = function (options : GetBatteryInfoOptions) {
const context = UTSAndroid.getAppContext();
if (context != null) {
const manager = context.getSystemService(
Context.BATTERY_SERVICE
) as BatteryManager;
const level = manager.getIntProperty(
BatteryManager.BATTERY_PROPERTY_CAPACITY
);

let ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
let batteryStatus = context.registerReceiver(null, ifilter);
let status = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
let isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL;

const res : GetBatteryInfoSuccess = {
errMsg: 'getBatteryInfo:ok',
level,
isCharging: isCharging
}
options.success?.(res)
options.complete?.(res)
} else {
let res = new GetBatteryInfoFailImpl(1001);
options.fail?.(res)
options.complete?.(res)
}
}

比如上方代码:通过 import BatteryManager from "android.os.BatteryManager"可以直接导入使用 Android 的 BatteryManager对象。

可以看到,在 uni-app x 你是可以“代码混写”的,所以与传统的 uni-app 不同,uni-app 依赖于定制 TypeScript 的 uts 和 uvue 编译器。

具体是:

1)uts 和 ts 有相同的语法规范,并支持绝大部分 ES6 API ,在编译时会把内置的如Array、Date、JSON、Map、Math、String等内置对象转为 Kotlin、Swift、ArkTS 的对象等,所以也不需要有 uts 之类的虚拟机,另外 uts 编译器在处理特定平台时,还会调用相应平台的原生编译器,例如 Kotlin 编译器和 Swift 编译器。

2)uvue 编译器基于 Vite 构建,并对它进行了扩展,大部分特性(如条件编译)和配置项(如环境变量)与 uni-app 的 Vue3 编译器保持一致,并且支持 less、sass、ccss 等 CSS 预处理器,例如 uvue 的核心会将开发者使用 Vue 语法和 CSS 编写的页面,编译并渲染为 ArkUI。

而在 UI 上,目前除了编译为 ArkUI 之外,Android 和 iOS 其实都是编译成原生体系,目前看在 Android 应该是编译为传统 View 体系而不是 Compose ,而在 iOS 应该也是 UIKit ,按照官方的说法,就是性能和原生相当。

所以从这点看,uni-app x 是一个类 RN 的编译时框架,所以,它的局限性问题也很明显,因为它的优势在于编译器转译得到原生性能,但是它的劣势也是在于转译。

具体是:

  • 1)不同平台翻译成本较高,并不支持完整的语言,阉割是必须的,API 必然需要为了转译器而做删减,翻译后的细节对齐于优化会是最大的挑战;

  • 2)iOS 平台还有一些骚操作,保留了可选 js 老模式和新 swift 模式,核心是因为插件生态,官方表示 js 模式可以大幅降低插件生态的建设难度, 插件作者只需要特殊适配 Android 版本,在iOS和Web端仍使用 ts/js 库,可以快速把 uni-app/web 的生态迁移到 uni-app x;

  • 3)生态支持割裂,uni-app 和 uni-app x 插件并不通用;

  • 4)不支持 PC;

  • 5)HBuilderX IDE;

  • 6)·····

那么,你觉得 uni-app x 会是你跨平台选择之一么?(详见《uni-app x的鸿蒙NEXT开发指南》)

9、本文小结

最后,我们简单做个总结:


什么,你居然看完了?事实上我写完都懒得查错别字了,因为真的太长了。

10、参考资料

[1] 快速了解新一代跨平台桌面技术——Electron

[2] Electron初体验(快速开始、跨进程通信、打包、踩坑等)

[3] vivo的Electron技术栈选型、全方位实践总结

[4] 蘑菇街基于Electron开发IM客户端的技术实践

[5] 融云基于Electron的IM跨平台SDK改造实践总结

[6] 网易云信基于Electron的IM消息全文检索技术实践

[7] 得物基于Electron开发客服IM桌面端的技术实践

[8] 新QQ桌面版为何选择Electron作为跨端框架

[9] 全面解密新QQ桌面版的Electron内存优化实践

[10] 快速对比跨平台框架Electron、Flutter、Tauri、React Native等

[11] 环信基于Electron打包WebIM桌面端的技术实践

[12] 万字长文详解QQ Linux端实时音视频背后的跨平台实践

[13] 从理论到实践,详细对比Electron和Tauri的优劣

技术交流:

- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM

- 开源IM框架源码:github.com/JackJiang201备用地址点此) (本文已同步发布于:http://www.52im.net/thread-4843-1-1.html)