2024年3月

零售商家为什么要建设线上商城?

传统的实体门店服务范围有限,只能吸引周边500米以内的消费者。因此,如何拓展服务范围,吸引更多的消费者到店,成为了店家迫切需要解决的问题。

缺乏忠实顾客,客户基础不稳,往往是一次性购物,门店无法形成有效的顾客回流。在当前的市场环境下,构建并维护粉丝群体,成为了商家的核心竞争力。

运营成本不断增长,包括租金和人工成本的上涨,但是广告投放、宣传又成本高昂,且难以追踪效果,达不到预期目标。如何有效吸引新客和提升销售业绩,变得至关重要。

电商不断挤压生存空间,随着网购成为人们的一种生活习惯,由于其方便和价格优势,再加上退换货几乎不产生成本,电商对于实体门店构成了巨大的竞争压力。

系统定位

面向新零售连锁商家的线上商城系统,定位包括以下几个方面:

  • 拓宽门店的服务半径。通过开发商城小程序,能让5公里范围内的潜在顾客,通过搜索小程序发现商家,有效扩大了潜在顾客群体。
  • 支持多渠道引流、多种业务模式。商城小程序支持众多入口方式,如小程序二维码、微信搜索、朋友推荐、社交媒体分享、微信公众号链接等,为商家提供了丰富的引流手段。商家可依据自身需求,在小程序中开展多种业务,例如电商购物、O2O购物、卡券核销、预约服务等。
  • 提升用户使用体验,促进交易转化。商城小程序的设计无需下载安装,即用即走,带来流畅的交互体验,方便用户随时接触产品和服务。在线客服系统能够实时解答客户疑问,为用户提供全天候服务,显著提升消费体验。
  • 构建私域客户群。商家可利用线上多种场景吸引客户访问小程序,并引导客户关注公众号或加群,通过营销活动促进粉丝转化。通过会员制和积分系统,有效积累和管理私域客户资源。

业务分析

新零售线上商城系统需要满足两种核心业务模式:电商购物模式、O2O购物模式。

我们以瑞幸咖啡为例,下图为瑞幸小程序首页,有到店取、幸运送、电商购的购物入口,其中到店取、幸运送为O2O购物模式,电商购为电商购物模式。

Untitled

电商购物流程

Untitled

O2O购物流程

Untitled

两种业务模式的差异

消费场所的差异:

  • 电商购物模式:完全在线上进行,从进店、选择商品、下单、支付到收货,消费者在线上即可完成购物全过程。
  • O2O购物模式:结合了线上和线下的消费场景。消费者可能在线上选购商品或服务,但可能在实体店进行自提或体验服务。

服务范围的差异

  • 电商购物模式:通常覆盖全国地区,不太受地理位置的限制。
  • O2O购物模式:服务范围受限于实体店的位置,更侧重于本地化服务。

物流配送的差异

  • 电商购物模式:依赖于第三方物流或自建物流进行商品配送,消费者通常在家中等待收取快递。
  • O2O购物模式:消费者可以到店自提商品,或者通过骑手配送商品。

售后服务的差异

  • 电商购物模式:售后服务主要通过线上进行沟通和处理,包括退货、换货、维修等。
  • O2O购物模式:售后服务可以在线上进行,也可以提供线下服务点,让消费者有更多选择。

线上商城概念模型设计

Untitled

订单域的聚合根:

  • 订单:客户提交购物请求后,生成的买卖合同,通常包含渠道信息、客户信息、下单日期、所购买的商品或服务明细、价格、数量、收货地址以及支付方式等详细信息。
  • 子订单:为了更高效地进行履约,大订单可能会被拆分成多个子订单,子订单会根据商品类型、配送地址、仓库位置或供应商等因素进行拆分。

其他实体:

  • 渠道信息:记录订单是通过哪个渠道购买,比如,电商平台、O2O平台、或者是门店。了解这些信息可以帮助我们更好地分析销售策略,以及客户的购买行为。
  • 客户信息 :客户的个人信息,例如客户名称、客户类型、客户生日等,这些信息也可用于后续的推广活动和客户服务。
  • 营销信息 :包括订单中应用的促销活动、折扣、积分使用等信息。营销信息有助于商家跟踪促销活动的效果,和客户的购买偏好。
  • 收发货信息 :详细记录了商品买家、卖家的收发货信息,包括发货地址、收货地址、联系方式等。
  • 支付信息:记录客户选择的支付方式、支付状态、支付时间、支付金额等信息。支付信息对于财务管理和订单结算流程至关重要。
  • 交付信息 :记录了商品交付的详细情况,比如预计送货时间、预计送达时间、预估费用等。

订单域的每个实体都扮演着关键角色,确保客户订单能够高效、准确地被处理和交付。

当然,这里只列举了订单域的核心实体,线上商城还需要其他关联系统支撑,其他关联系统的架构设计,可以回看汤师爷之前写文章。

线上商城系统的应用架构

Untitled

应用层定义了软件的应用功能,负责接收用户的请求,协调领域层能力来执行任务,并将结果返回给用户,模块包括:

  • C端服务模块:
    • 为消费者提供交易链路的核心功能,包括品牌首页、基于LBS进店、加购、结算、下单、支付、订单列表、个人中心等功能。
  • B端管理模块:
    • 渠道管理:负责管理所有的销售渠道,包括平台渠道的开通、授权绑定等,确保所有渠道都能顺利运营。
    • 客户管理:负责管理所有客户信息,如注册、登录、客户信息更新等。同时,要收集和分析客户的行为数据,了解他们的需求和喜好,对客户进行精细化的运营。
    • 店铺装修:负责商城界面的设计和布局,包括首页、商品展示、推广活动、用户体验优化等。
    • 商品管理:负责维护商品信息,以及商品的上下架、分类、定价等。
    • 库存管理:监控和管理所有商品的库存,确保库存数据的准确性。
    • 订单管理:负责订单的接收、处理、跟踪和确认,包括订单的支付、拣货、打包、发货、退换货等。
    • 营销管理:负责商城的营销活动管理,包括优惠券、打折、秒杀、团购等活动的创建和执行,以及营销效果的分析。
    • 配送管理:负责订单的配送,包括配送方式的选择、配送员的调度、配送过程的跟踪等。
    • 数据分析:对商城的运营数据进行分析,包括用户行为、销售情况、营销效果等,为决策提供数据支持。
    • 组织管理:负责系统内部的组织机构管理,包括门店的设立、人员分工、权限分配等。

领域层是业务逻辑的核心,专注于表示业务概念、业务状态流转和业务规则,沉淀可复用的服务能力。

  • 正向交易模块:包括购物车、订单确认、下单、改价、支付、发货、取消、确认收货等能力。
  • 逆向交易模块:
    • 面向C端:申请退款、上门取件、退款列表、退款列表、申请退换货、申请客服介入、退款详情等能力
    • 面向B端:协商记录、同意退货、同意退款、退货收货、主动退款、确认收货、换货发货、拒绝退货等能力。

这里只列举了订单域的核心能力,其他能力由关联系统提供,关联系统的架构设计,可以回看汤师爷之前写文章。

写在最后

线上商城系统架构设计主要解决了零售商家的服务范围限制、缺乏忠实顾客、运营成本增长和电商竞争压力等问题。

系统定位包括拓宽门店服务半径、支持多渠道引流、提升用户体验和构建私域客户群。

核心业务模式包括电商购物模式和O2O购物模式,这两种模式在消费场所、服务范围、物流配送和售后服务上有所差异。

线上商城的概念模型设计包括订单、子订单、渠道信息、客户信息、营销信息、收发货信息、支付信息和交付信息等实体。

应用架构包括C端服务模块和B端管理模块,领域层包括正向交易模块和逆向交易模块。

作者:vivo 互联网服务器团队-  Zeng Zhibin

介绍Java8虚拟机的内存区域划分、内存垃圾回收工作原理解析、虚拟机内存分配配置,介绍各垃圾收集器优缺点及场景应用、实践内存故障场景排查诊断,方便读者面临内存故障时有一个明确的思路和方向。

一、背景

Java是一种流行的编程语言,可以在不同的操作系统上运行。它具有跨平台、面向对象、自动内存管理等特点,Java程序在运行时需要使用内存来存储数据和程序状态。

Java的自动内存管理机制是由 JVM 中的垃圾收集器来实现的,垃圾收集器会定期扫描堆内存中的对象,检测并清除不再使用的对象,以释放内存资源。

Java的自动内存管理机制带来了许多好处,首先,它可以避免程序员手动管理内存时的错误,例如内存泄漏和悬空指针等问题。其次,它可以提高程序的运行效率,因为程序员不需要频繁地手动分配和释放内存,而是可以将更多时间和精力专注于程序的业务逻辑,最后,它可以提高程序的可靠性和稳定性,因为垃圾收集器可以自动检测和清除不再使用的内存资源,避免内存溢出等问题。

了解和掌握垃圾收集器原理可以帮助提高程序的性能、稳定性和可维护性。

名词解释:

响应速度
:响应速度指程序或系统对一个请求的响应有多迅速。比如,用户查询数据响应时间,对响应速度要求很高的系统,较大的停顿时间是不可接受的。

吞吐量
:吞吐量关注在一个特定时间段内应用系统的最大工作量,例如每小时批处理系统能完成的任务数量,在吞吐量方面优化的系统,较长的GC停顿时间也是可以接受的,因为高吞吐量应用更关心的是如何尽可能快地完成整个任务,不考虑快速响应用户请求。

GC导致的应用暂停时间影响系统响应速度,GC处理线程的CPU使用率影响系统吞吐量。

二、Java 8 的内存管理

2.1 JVM(Java虚拟机)内存划分

Java运行时数据区域划分,Java虚拟机在执行Java程序时,将其所管理的内存划分为不同的数据区域,每个区域都有特定的用途和创建销毁的时间。其中,有些区域在虚拟机进程启动时就存在,而有些区域则是随着用户线程的启动和结束而建立和销毁。这些数据区域包括程序计数器、虚拟机栈、本地方法栈、堆、方法区等,每个区域都有其自身的特点和作用。了解这些数据区域的使用方式和特点,可以更好地理解Java虚拟机的内存管理机制和运行原理。

JVM的内存区域划分可分为:1.堆内存空间、2.Java虚拟机栈区域、3.程序计数器、4.本地方法栈、5.元空间区域、6.直接内存。

图片

图片

  • 堆内存空间
    :JVM中占用内存空间最大的是堆,平常对象的创建大部分都是在堆上分配内存的,是垃圾回收的主要目标和方向。

  • 本地方法栈区域
    :Native Mehod Stack与Java虚拟机栈的作用非常相似,区别是Java虚拟机栈为虚拟机执行Java方法或者为字节码而服务,本地方法栈是为了Java 虚拟机栈得到Native方法。

  • Java虚拟机栈区域
    :负责Java的解释过程、程序的执行过程、入栈和出栈,它是与线程相关的,当启动一个新的线程时,Java程序就会分配一个Java 虚拟机栈提供运行;Java 虚拟机栈从方法入栈到具体字节码执行是一个双层栈结构,可以栈里包含栈。

  • 程序计数器
    :记录线程执行位置,线程私有,因为操作系统不停的调度,无法获取到线程被调度之前的位置,程序计数器提供了这样一个线程执行位置。

  • 元空间区域
    :在原来的老的Java 7之前划分中,永久代用来存放类的元数据信息、静态变量以及常量池等。在现在Java8后类的元信息存储在元空间中,静态变量和常量池等并入堆中,相当于原来的永久代中的数据,被元空间和堆内存给瓜分了。

  • 直接内存
    :使用了Java 的直接内存的API的内存,例如缓冲ByteBuffer,可以控制虚拟机参数调整大小,而本地内存是使用了native函数操作的内存,是不受JVM管理控制。

堆内存空间

JVM回收的主要目标是堆内存,对象主要的创建分配内存在堆上进行,堆可以想象成一个对象池子,对象不停创建放入池子中,而JVM垃圾回收是不停的回收池子中一些被标记为可回收对象的对象,启动回收线程进行打扫战场,当回收对象的速度赶不上程序的创建时,池子就会立马满,当满了之后从而发生溢出,就是常见的OOM。

GC的速度和堆的内存中存活对象的数量有关,与堆内存所有的对象无关,GC的速度和堆内存的大小无关,如一个4GB大小的堆内存和一个16GB的堆内存,只要2个堆内存存活对象都是一样多的时候,GC速度都是基本差不多。每次垃圾回收也不是必须要把垃圾清理干净,重要的是保证不把正在使用的对象给标记清除掉。

2.2 堆内存管理

JVM中占用内存空间最大的是堆内存,平常对象的创建大部分都是在堆上分配内存的,是Java垃圾回收的主要目标和方向、是 Java内存管理机制的核心组成部分,它可以自动管理 Java程序的内存分配和释放,Java垃圾收集器可以自动检测和回收不再使用的内存,以便重新分配给其他需要内存的程序。这种自动内存管理的机制可以提高程序的运行效率和可靠性,防止因内存泄漏等问题导致程序崩溃或性能下降,Java 垃圾收集器使用了不同的垃圾回收算法和垃圾收集器实现,以适应不同的应用场景和需求。Java垃圾收集器的性能特征和优化技术也是 Java程序员需要了解和掌握的重要知识。

因此,了解 Java垃圾回收的背景、原理和实践经验对于编写高效、可靠的 Java程序非常重要。

2.2.1 对象如何被判断为可回收

JVM怎么判断堆内存里面的对象是否可回收的,就是当一个对象没有任何引用指向它了,它就是可回收对象,判断的方式有两种算法,一个是引用计数法,一个是可达性分析法。

可回收对象:

图片

(1)引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它时,这个计数器值加一,当引用失效断开时,计数器值就减一,在任何时刻时计数器为0的时候,代表这个对象是可以被回收的,没有任何引用使用它了。

图片

引用计数法是有缺点,当对象直接互相依赖引用时,这些对象的计数器都不能为0,都不能被回收。

(2)可达性分析法

它使用tracing(链路追踪)方式寻找存活对象的方法,通过一些列称为“GC Roots”的对象作为初始点,从这些初始点开始向下查找,直到向下查找没有任何链路时,代表这个对象可以被回收,这种算法是目前Java唯一且默认使用来判定可回收的算法。

图片

2.2.2 GC Roots的概念和对象类型

  1. Java 虚拟机栈中引用的对象,例如各个线程被调用的方法栈用到的参数、局部变量或者临时变量等。

  2. 方法区的静态类属性引用对象或者说Java类中的引用类型的静态变量。

  3. 方法区中的常量引用或者运行时常量池中的引用类型变量。

  4. JVM内部的内存数据结构的一些引用、同步的监控对象(被修饰同步锁)。

  5. JNI中的引用对象。

当然,被GC Roots追溯到的对象不是一定不会被垃圾回收,具体需要看情况,Java 对象与对象引用存在四种引用级别:分别是强引用、软引用、弱引用、虚引用,默认的对象关系是强引用,只有在和GCRoots没有关系时才会被回收;软引用用于维护一些可有可无的对象,当内存足够时不会被回收;弱引用只要发生了垃圾回收就会被清理;虚引用人如其名形同虚设,任何对象都与它无关。

2.2.3 垃圾对象回收算法

当JVM定位到了那些对象可回收时,这个时候是通过三个算法标记清除,分别是标记清除算法、复制算法、标记压缩算法。

(1)标记清除算法

首先标记出所有需要回 收的对象,在标记完成后,统一回收掉所有被标记的对象,但是该算法缺点是执行效率低,当大量对象时需要大量标记和清理动作,而且容易产生内存碎片化,当需要一块连续内存时,会因为碎片化无法分配。

图片

(2)标记压缩算法

标记压缩算法跟清除算法很像,只不过它对内存进行了整理, 让存活对象都向内存空间的一端移动,然后将边界的其它对象全部清理,这样能达到内存碎片化问题,不过它比清除算法多了移步动作。

图片

(3)复制算法

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,将存活对象复制到一块空置的空间里,然后将原来的区域全部清理,缺点是需要额外空间存放存活对象。

图片

2.2.4 分代垃圾回收模型概念和原理

堆内存分代模型图

图片

当JVM进行GC(垃圾回收)时,JVM会发起“Stop the world”,所有的业务线程都进行停止,进入SafePoint状态,JVM回收垃圾线程开始进行标记和追溯,如何解决这种停止和如何减少STW的时间呢?

目前主流垃圾收集器采用分代垃圾回收方式,大部分对象的声明周期都比较短,只有少部分的对象才存活的比较长,分代垃圾回收会在逻辑上把堆内存空间分为两部分,一部分为年轻代,一部分为老年代。

(1)年轻代空间

年轻代主要是存放新生成的对象,一般占用堆空间的三分之一空间,因为会频繁创建对象,所以年轻代GC频率是最高的。

分为Eden空间、Survivor1(from)区、Survivor2(to)区,S1和S2总要有一块空间是空的,为了方便年轻代存活对象来回存放,晋升存活对象年龄。

三个区的默认比例是8:1:1,可以通过配置参数调整比例。

年轻代回收发起Minor GC(YongGC),当Eden内存区域被占满之后就发起GC,短暂的STW,基于垃圾收集器。

(2)老年代空间

是堆内存中最大的空间, ,里面的对象都是比较稳定或者老顽固,GC频率不会频繁执行。

图片

老年代对象:

  1. 正常提升
    :由年轻代存活对象年龄到达阈值时,这个对象则会被移动到老年代中。

  2. 分配担保
    :如果年轻代中的空间不足时,此时有新的对象需要分配对象空间,需要依赖其它内存进行分配担保,老年代担保直接创建。

  3. 大对象
    :当创建需要大量连续内存空间的对象时,如长字符串或者数组等,大小超过了阈值时,直接在老年代分配。

  4. 动态年龄对象
    :有的垃圾收集器不需要到达指定年龄大小直接晋升老年代,比如相同年龄的对象的大小总和 > Survivor空间的50%, 年龄大于等于该年龄对象直接移动老年代,无需等待正常提升。

老年代回收发起Major GC / FULL GC,当老年代满时会触发MajorGC,通常至少经历过一次Minor GC,再紧接着进行Major GC, Major GC清理Tenured区,用于回收老年代(CMS才能单独清理)。

FUll GC:清除整个堆空间,一般来说是针对整个新生代、老生代、元空间的全局范围的清理。

不管是Major GC还是 Full GC, STW的耗时都是Ygc的十倍以上,所以说对象能在年轻代被回收是最优的。

Full GC触发条件:

  • 老年代空间不足。

  • 元空间不足扩容导致。

  • 程序代码执行System.gc时可能会执行。

  • 当程序创建一个大对象时,Eden区域放不下大对象,老年代内存担保分配,老年代也不足空间时。

  • 年轻代存留对象晋升老年代时,老年代空间不足时。

2.2.5 Java对象内存分配过程

图片

对象的分配过程

  1. 编译器通过逃逸分析优化手段,确定对象是否在栈上分配还是堆上分配。

  2. 如果在堆上分配,则确定是否大对象,如果是则直接进入老年代空间分配, 不然则走3。

  3. 对比tlab, 如果tlab_top + size <= tlab_end, 则在tlab上直接分配,并且增加tlab_top值,如果tlab不足以空间放当前对象,则重新申请一个tlab尝试放入当前对象,如果还是不行则往下走4。

  4. 分配在Eden空间,当eden空间不足时发生YGC, 幸存者区是否年龄晋升、动态年龄、老年代剩余空间不足发生Full GC 。

  5. 当YGC之后仍然不足当前对象放入,则直接分配老年代。

TLAB
作用原理
:Java在内存新生代Eden区域开辟了一小块线程私有区域,这块区域为TLAB,默认占Eden区域大小的1%, 作用于小对象,因为小对象用完即丢,不存在线程共享,快速消亡GC,JVM优先将小对象分配在TLAB是线程私有的,所以没有锁的开销,效率高,每次只需要线程在自己的缓冲区分配即可,不需要进行锁同步堆 。

对象除了基本类型的不一定是在堆内存分配,在JVM拥有逃逸分析,能够分析出一个新的对象所拥有的范围,从而决定是否要将这个对象分配到堆上,是JVM的默认行为;Java 逃逸分析是一种优化技术,可以通过分析 Java 对象的作用域和生命周期,确定对象的内存分配位置和生命周期,从而减少不必要的内存分配和垃圾回收。可以在栈上分配,可以在栈帧上创建和销毁,分离对象或标量替换,同步消除。

public class TaoYiFenxi {
 
    Object obj;
 
    public void setObj() {
        obj = new Object();
    }
 
    public Object getObject() {
        Object obj1 = new Object();
        return obj1;
    }
 
 
    public void test1() {
        synchronized (new Object()) {
 
        }
    }
 
}

2.2.6 JVM垃圾收集器特点与原理

(1)Serial垃圾收集器、Serial Old垃圾收集器

图片

Serial收集器采用复制算法, 作用在年轻代的一款垃圾收集器,串行运行,执行过程中会STW,是使用单个线程进行垃圾回收,响应速度优先。

Serial Old 收集器采用标记整理算法,作用在老年代的一款收集器,串行运行,执行过程中会暂停所有用户线程,会STW,使用单个线程进行垃圾回收,响应速度优先。

  • 使用场景:适合内存小几十兆以内,比较适合简单的服务或者单CPU服务,避免了线程交互的开销。

  • 优点:小堆内存且单核CPU执行效率高。

  • 缺点:堆内存大,多核CPU不适合,回收时长非常长。

(2)Parallel Scavenge垃圾收集器、Parallel Old垃圾收集器

图片

Parallel Scavenge垃圾收集器采用了复制算法,作用在年轻代的一款垃圾收集器,是并行的多线程运行,执行过程中会发生STW,关注与程序吞吐量。

Parallel Old垃圾收集器采用标记整理算法,作用,作用在老年代的一款垃圾收集器, 是并行的多线程运行,执行过程中会发生STW,关注与程序吞吐量。

Parallel Scavenge + Parallel Old组合是Java8当中默认使用的一个组合垃圾回收。

所谓的吞吐量是CPU用于运行用户代码时间与CPU总消耗时间的比值,也就是说吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集器时间), 录入程序运行了100分钟,垃圾收集器花费时间1分钟,则吞吐量达到了99%。

  • 使用场景:适用于内存在几个G之间,适用于后台计算服务或者不需要太多交互的服务,保证吞吐量的服务。

  • 优点:可控吞吐量、保证吞吐量,并行收集。

  • 缺点:回收期间STW,随着堆内存增大,回收暂停时间增大。

(3)Par New垃圾收集器

Par New垃圾收集器采用了复制算法,作用在年轻代的一款垃圾收集器, 也是并行多线程运行,跟Parallel非常相似,是它的增强版本,或者说是Serial收集器的多线程版本,是搭配CMS垃圾收集器特制的一个收集器。

  • 使用场景:搭配CMS使用

(4)CMS垃圾收集器

CMS是一款多线程+分段操作的一款垃圾收集器。其最大的优点就是将一次完整的回收过程拆分成多个步骤,并且在执行的某些过程中可以使用户线程可以继续运行,分别有初始标记,并发标记,重新标记,并发清理和并发重置。

图片

CMS是一款多线程+分段操作的一款垃圾收集器。其最大的优点就是将一次完整的回收过程拆分成多个步骤,并且在执行的某些过程中可以使用户线程可以继续运行,分别有初始标记,并发标记,重新标记,并发清理和并发重置。

CMS分段

  • 初始标记阶段
    , 这个阶段会暂停用户线程, 扫描所有的根对象,因为根对象比较少,所以一般stw时间都非常短。

  • 并发标记阶段
    ,这个阶段与用户线程一起执行,会一直沿着根往下扫描,不停的识别对象是否为垃圾,标记,采用了三色算法, 在对象头(Mark World)标识了一个颜色属性,不同的颜色代表不同阶段,扫描过程中给与对象一个颜色,记录扫描位置,防止cpu时间片切换不需要重新扫描。

  • 重新标记阶段
    , 这个阶段暂停用户线程, 修正一些漏标对象,回扫发生引用变化的对象。

  • 并发清理阶段
    , 这个阶段与用户线程一起执行,标记清除已经成为垃圾的对象。

三色标记

  • 黑色
    :代表了自己已经被扫描完毕,并且自己的引用对象也已经确定完毕。

  • 灰色
    :代表自己已经被扫描完毕了, 但是自己的引用还没标记完。

  • 白色:则代表还没有被扫描过。

标记过程结束后,所有未被标记的对象都是不可达的,可以被回收。

图片

三色标记算法的
问题场景
:当业务线程做了对象引用变更,会发生B对象不会被扫描,当成垃圾回收。

public class Demo3 {
 
    public static void main(String[] args) {
        R r = new R();
        r.a = new A();
        B b = new B();
        // GCroot遍历R, R为黑色, R下面的a引用链还未扫完置灰灰色,R.b无引用, 切换时间分片
        r.a.b = b;
        // 业务线程发生了引用改变, 原本r.a.b的引用置为null
        r.a.b = null;
        // GC线程回来继续上次扫描,发现r.a.b无引用,则认为b对象无任何引用清除
        r.b = b;
        // GC 回收了b, 业务线程无法使用b
    }
}
 
class R {
    A a;
    B b;
}
 
class A {
    B b;
}
 
class B {
}

图片

当GC线程标记A时,CPU时间片切换,业务线程进行了对象引用改变,这时候时间片回到了GC线程,继续扫描对象A, 发现A没有任何引用,则会将A赋值黑色扫描完毕,这样B则不会被扫描,会标记B是垃圾, 在清理阶段将B回收掉,错误的回收正常的对象,发生业务异常。

CMS基于这种错误标记的解决方案是采取写屏障 + 增量更新Incremental Update , 在业务线程发生对象变化时,重新将R标识为灰色,重新扫描一遍,Incremental Update 在特殊场景下还是会产生漏标。

图片

public class Demo3 {
 
    public static void main(String[] args) {
        // Incremental Update还会产生的问题
        R r = new R();
        A a = new A();
        A b = new A();
        r.a1 = a;
        // GC线程切换, r扫完a1, 但是没有扫完a2, 还是灰色
        r.a2 = b;
        // 业务线程发生引用切换, r置灰灰色(本身灰色)
        r.a1 = b;
        // GC线程继续扫完a2, R为黑色, b对象又漏了~
    }
}
 
class R {
    A a1;
    A a2;
}
 
class A {
}

当GC 1线程正在标记O, 已经标记完O的属性 O.1, 准备标记O.2时,业务线程把属性O,1 = B,这时候将O对象再次标记成灰色, GC 1线程切回,将O.2线程标记完成,这时候认为O已经全部标记完成,O标记为黑色, B对象产生了漏标, CMS针对Incremental Update产生的问题,只能在remark阶段,暂停所有线程,将这些发生过引用改变过的,重新扫描一遍。

  • 使用场景:适用于互联网或者 B/S服务, 响应速度优先,适合6G左右。

  • 优点:并发收集, 低停顿,回收过程中最耗时的是并发标记和并发清除,它都能与用户线程保持一起工作。

  • 缺点:

收集器对CPU的资源非常敏感,会占用用户线程部分使用,导致程序会变得缓慢,吞吐量下降。

无法处理浮动垃圾,在并发清理阶段用户线程还是在运行,这时候产生的新垃圾无法在这次当中处理,只有等待下次才会清理。

因为CMS使用了Incremental Update,remark阶段还是会所有暂停,重新扫描发生引用改变的GC root,效率慢耗时高。

因为收集器是基于标记清除算法实现的,所以在收集器回收结束后,内存会产生碎片化,当碎片化非常严重的时候,这时候有大对象进入无法分配内存时会触发FullGC,特殊场景下会使用Serial收集器,导致停顿不可控。

(5)G1垃圾收集器

G1也是采用三色标记分段式进行回收的算法, 不过它是写屏障 + STAB快照实现,G1设定的目标是在延迟可控(低暂停)的情况下获得尽可能高的吞吐量,仍然可以通过并发的方式让Java 程序继续运行,G1垃圾收集器在很多方面弥补了CMS的不足,比如CMS使用的是mark-sweep标记清除算法,自然会产生内存碎片(CMS只能在Full GC时,STW 整理内存碎片),然而G1整体来看是基于标记整理算法实现的收集器,但是从局部来看也是基于复制算法实现的,高效的整理剩余内存,而不需要管理内存碎片它。

G1同样有年轻代和老年代的概念,只不过物理空间划分已经不存在,逻辑分区还存在,G1会把堆切成若干份,每一份当作一个目标,在部分上目标很容易达成,G1在进行垃圾回收的时候,将会根据最大停顿时间设置值动态选取部分小堆区垃圾回收。

图片

G1的特点是尽量追求吞吐量,追求响应时间,并发收集,压缩空闲空间不会延长GC暂停时间,更容易预测GC暂停时间,能充分利用CPU、多核环境下的硬件优势,使用多个CPU对STW进行控制(200ms以内)灵活的分区回收,优先回收花费时间少的或者垃圾比例高的region新老比例也是动态调整,不需要配置;年龄晋升也是15,但是可以动态年龄,当幸存者region超过了50时,会把年龄最大的放入老年代。

G1动态Y区域设置,G1每个分区都可能是年轻代或者老年代,但是同一时刻只属于一个代,分代概念还存在,逻辑上分代方便复用以前分代逻辑,在物理上不需要连续,这样能带来额外好处,有的分区内垃圾比较多,有的分区比较少,G1会优先回收垃圾比较多的分区,这样可以花费少量的时间来回收这些分区垃圾,即收集最多垃圾分区;但是新生代回收不适合这种,新生代达到阈值时发生YGC,对整个新生代进行回收或者晋升幸存,新生代也分区是方便动态调整分区大小,在进行垃圾回收时,会将存活对象拷贝到另一个可用分区上,这样也能避免一定程度的内存碎片化过程,每个分区的大小都是在1M- 32M之间,取决2的幂次方。

Humingous
:如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响;为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

CardTable
:记录每一块card内存区域是否dirty,如果在发生YGC时,怎么知道那些是存活对象,并且其它代区域有没有引用这部分对象,于是把内存划分了很多card区域, 每个区域大小不超过512b,当该card区域里的对象有引用关系,将当前card置为“dirty”, 并且使用卡表(CardTable)来记录每一块card是否dirty,在进行GC时,不用遍历所有的空间, 只需要遍历卡表中为"dirty"或者说布尔符合条件的card区域进行回扫。

图片

CSet
:Collection SET用于记录可被回收分区的集合组, G1使用不同算法,动态的计算出那些分区是需要被回收的,将其放到CSet中,在CSet当中存活的数据都会在GC过程中拷贝到另一个可用分区,CSet可以是所有类型分区,它需要额外占用内存,堆空间的1%。

RSet
:RememberedSet 每个Region都有一个Rset,是一个记录了其他Region中的对象到本身Region的引用,它可以使得垃圾收集器不需要扫描整个堆去找到谁的引用了当前分区对象,是G1高效回收的关键点,也是三色算法的一个以来点。

图片

RSet和卡表的区别是什么?

卡表记录的是堆内存中card有没有变成"dirty", 但是它本身不知道dirty里面哪些是引用了的对象,它是一个大维度的一个记录,RSet是记录自身Region中对象引用了其它Region中的那些对象,详细的记录对方引用对象信息,G1使用了两者的结合,实现了增量式的垃圾回收,并优化跨区引用的最终处理。

SATB算法
:是一种基于快照的算法,它可以避免在垃圾回收时出现对象漏标或者重复标记的问题,从而提高垃圾回收的准确性和效率,在垃圾回收开始时,对堆中的对象引用进行快照,然后在并发标记阶段中记录下所有被修改过对象引用,保存到satb_mark_queue中,最后在重新标记阶段重新扫描这些对象,标记所有被修改的对象,保证了准确性和效率。

SATB算法在remark阶段不需要暂停遍历整个堆对象,只需要扫描“satb_mark_queue”队列中的记录,避免了这个阶段长耗时,而cms的增量算法在这个阶段是需要重新扫描GC Roots标记整个堆对象,导致了不可控时间暂停,总的来说G1是通过回收领域应用并行化策略,将原来的几块大内存块回收问题,演变成了N个小内存块回收,使得回收效率可以高度并行化,停顿时间可控,可以与用户线程并发执行,将一块内存分而治之。

图片

G1默认当分区内存占用阈值达到总内存的45%,会发生Mixed gc(混和GC),YoungGC + 并发回收Mixed GC过程:初始标记(stw)、并发标记、最终标记(重新标记stw)、筛选回收(stw并行)。

  • 使用场景:响应速度优先,较高的吞吐量,面向服务端,使用内存6G以上。

  • 优点:并行与并发收集,分代分区收集,优先垃圾收集,空间整合,可控或者可预测停顿时间。

  • 缺点:

收集中产生内存,G1的每个region都需要有一份记忆集和卡表记录跨代指针,这导致记忆集可能占用堆空间10-20%甚至更多空间。

执行过程中额外负载开销加大,写屏障进行维护卡表操作外,还需要原始快照能够减少并发标记和重新标记阶段的消耗,避免最终标记阶段停顿过长,运行过程中会产生由跟踪引用变化带来的额外开销负担,比CMS增量算法消耗更多,CMS的写屏障实现直接是同步操作, 而G1是把写屏障和写后屏障中要做的事情放到队列里异步处理。

G1对于Full GC是没有处理流程, 一旦发生Full GC G1的回收执行的是单线程的Serial回收器进行回收。

2.2.7 垃圾收集器配置使用

机器配置:64位 4C8G

Java 程序使用CMS收集器进行内存垃圾回收初始内存划分情况:

-Xms4096M -Xmx4096M -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/{runuser}/logs/other -XX:+UseConcMarkSweepGC

图片

CMS 跟 parNew占比情况, 默认下 ParNew占用整个堆的空间为:机器位数 * CPU核数 * 13 /10 , 当前机器配置计算得出 64 * 4 * 13 / 10 = 332M , 与图上数值差别不大。

Java程序使用G1收集器进行内存垃圾回收初始内存划分情况:

-Xms4096M -Xmx4096M -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/{runuser}/logs/other -XX:+UseG1GC

图片

G1 新老年代的占比是动态调整, 随着运行时根据实际情况划分空间。

Java8默认ParallerGC收集器初始内存划分情况:

图片

parallel GC回收器默认堆old区与young区内存大小比例 2:1, 图上数值差别不大。

三、内存诊断实践

3.1 内存快照生成

当发生线上应用告警,告警相关内存故障问题时, 应当如何进行故障排查呢?首先应用在发生内存溢出无法执行时,应DUMP当前内存快照,需要在Java程序执行启动命令时添加上:

-XX:+HeapDumpOnOutOfMemoryError

-XX:HeapDumpPath=${filePath} 参数

当发生时自动生成一份当前内存快照,方便与开发人员使用快照文件进行问题诊断分析。

在Java应用运行时,想手动生成内存快照,可以使用JDK自带几个问题排查工具,可以使用jmap工具生成指定PID内存快照,不过需要耗费较长的一个时间,会暂停应用程序执行,使用jcmd工具可以快速的DUMP内存快照,因为在堆转储存文件过程中,jcmd可以利用虚拟机中的一些优化技术,例如分代堆、增量式垃圾回收等技术,相比传统的jmap效率高很多,一般来说在DUMP内存前会进行一次

Full FC,可以指定屏蔽这次Full GC,保留当前所有内存中的对象。

除了自带的内存诊断工具, 也可以使用Arthas诊断工具,提供了多个命令来帮助诊断内存问题,例如 dashboard(当前Java程序内存实时数据面板)、JVM(查看当前JVM信息,包括使用的gc收集器、内存分区分布情况等信息)、heapdump(当前内存快照类似jmap命令的heap dump)、memory(当前内存分区及占用情况)、monitor(监控模式,可监控内存及查看对象占用情况)profiler(火焰图可以输出多种火焰图,内存分区占用火焰图)等相关内存命令。这些命令可以帮助获取应用程序的内存快照、堆内存使用情况等信息,能快速定位内存问题。

引用:
Arthas 命令列表

3.2 dump内存快照分析

(1)jhat 是 Java 开发工具包自带的一款堆内存分析工具,它可以帮助解决 Java 应用程序的内存问题。Jhat 可以读取 Java 应用程序生成的堆转储文件,并以 HTML 格式展示内存中的对象信息和引用关系,支持 OQL 查询和灵活的过滤和排序功能。

用例
jhat E:\diydump\Java_pid2680.hprof

图片

  • All classes including platform
    :列举应用程序中所有类的信息,并快速定位内存问题。
  • Show all members of the rootset
    :显示堆内存中所有根对象的信息,包括系统对象、静态对象、本地对象等。

  • Show instance counts for all classes (including platform)
    :显示所有类的实例数量。

  • Show heap histogram
    :显示程序堆内存的直方图,可以知道每个类的实例数量和占用内存大小等信息,快速知道内存泄漏原因。

(2)jvisualvm也是Java 开发工具包里自带的一款图形化工具,可以用于监控和诊断Java应用程序的性能问题。使用它可以实时查看Java 应用程序的内存使用情况、CPU使用情况、线程情况等,并可以进行内存分析、CPU分析、线程分析等内容。

以Java_pid2680.hprof为例,进行内存分析内存泄漏原因:

图片

(3)MAT 是基于Eclipse的内存分析工具,是一个快速、功能丰富的Java内存分析工具,能够快速的分析出dump文件中各项结果,快速给出内存泄漏原因报告。

还是以Java_pid2680.hprof文件进行分析,比原生的jhat方便很多,功能也比原生的更加丰富:

图片

MAT的一些常用功能点介绍(如图所示):

  • Overview
    标签内容有比较多块内容,其中details末块介绍总共使用内存大小,类的数量,实例的数量,类的加载器,以及实例的内存直方图;

  • Biggest Objects by Retained Size
    模块,使用了饼状图列出了当前内存中占用最大的几个对象,按照百分比划分,点击不同的饼状块能够看到具体对象及其对象属性等信息;

  • actions
    模块,这里拥有不同的分析功能,Histogram生成视图列出每个类所对应的对象个数以及占用内存大小,Dominator Tree生成视图寻找出大对象,每个实例对象的内存占比比重;

  • Reports
    模块是生成报告,其中Leak Suspects可以自动分析内存泄漏主要原因报告,可以通过报告准确定位泄漏原因或者可能造成泄漏的原因,并且可以定位到具体累积实例,线程stack等信息。

例子中:leak Suspects报告给出“0xfe3be480” 非常多内存, Gc root Thread 所引用,在发生gc时,不是可回收对象,无法回收内存,导致内存溢出。

图片

四、总结

本文介绍了Java程序中的内存模型,内存模型划分多份内存区域,不同区域的作用介绍及不同区域的线程之间的内存共享范围,可以帮助开发人员更加理解Java 中内存管理的机制和原理。

堆是内存模型中最大的一块内存区域,以堆的空间划分详细的介绍了内存分代,部分垃圾收集器即是物理分代和逻辑分代,G1收集器则物理不分代逻辑保留了以前分代,讲述了不同收集器的原理实现和优缺点,可以根据项目的业务属性,机器配置等因素选择最优的收集器,帮助程序使用最优的收集器可以使得程序的吞吐量和响应速度达到最佳状态。还讲述了不同的参数调优收集器,并且当发生了程序内存溢出崩溃,如何进行内存分析,介绍不同工具的使用,快速定位内存溢出的罪魁祸首,从而在代码层面上根本解决这类问题。

我们是
袋鼠云数栈 UED 团队
,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:
霜序
本文首发于:
https://juejin.cn/post/7299384698882539574

在大数据业务中,时常会出现且或关系逻辑的拼接,有需要做成可视化配置,如下图

file

目前该组件已经开源到了我们组件库
dt-react-component

详细查看

前期分析

需要确定好数据结构

因为是嵌套结构,可以通过 ➕➖ 来增加层级或者数据,因此采用树形结构来存储数据。

export interface IFilterValue<T> {
  key: string;
  level?: number; // 当前节点的层级,用于判断一些按钮的展示
  type?: number; // 当前节点的条件关系,1 | 2
  rowValues?: T; // Form 节点的相关的信息(子节点无条件节点时才有)
  children?: IFilterValue<T>[]; // 子节点的信息(子节点存在条件节点时才有)
}

上述的图片的数据为:

 {
    "key": "qTipLrlUt",
    "level": 1,
    "children": [
        {
            "key": "B6Jrbqcfof",
            "type": 2,
            "level": 2,
            "children": [
                {
                    "rowValues": {
                        "condition": 1,
                        "rowPermission": ""
                    },
                    "key": "deg8x8UgZ",
                    "level": 2
                },
                {
                    "key": "_sczw_1h8H",
                    "type": 1,
                    "level": 3,
                    "children": [
                        {
                            "key": "Z5UkUPJoA",
                            "rowValues": {
                                "condition": 1,
                                "rowPermission": ""
                            },
                            "level": 3
                        },
                        {
                            "key": "MbpJILqHGx",
                            "rowValues": {
                                "condition": 1,
                                "rowPermission": ""
                            },
                            "level": 3
                        }
                    ]
                }
            ]
        },
        {
            "rowValues": {
                "condition": 1,
                "rowPermission": ""
            },
            "key": "qx6bG0o5H",
            "level": 1
        }
    ],
    "type": 1
}

明确每个操作按钮的实现

明确组件的封装

  • 组件只希望实现条件节点/线条/操作按钮的展示,因此后面的组件需要作为参数 component 传入
  • 组件对层级有一个控制,支持 maxLevel 来控制
  • 每一次新增数据的时候,默认值需要传入
    initValues
  • 支持两种模式 编辑状态 和 查看状态
  • 支持受控和非受控两种模式

组件封装

FilterRules

提供给用户使用的组件,实现数据的增删改查操作。可以采用受控和非受控两种模式。
它接受的参数如下:

interface IProps<T> {
  value?: IFilterValue<T>;
  disabled?: boolean;
  maxLevel?: number;
  initValues: T;
  notEmpty?: { data: boolean; message?: string };
  component: (props: IComponentProps<T>) => React.ReactNode;
  onChange?: (value: IFilterValue<T> | undefined) => void;
}
export const FilterRules = <T>(props: IProps<T>) => {
  const {
    component,
    maxLevel = 5,
    disabled = false,
    notEmpty = { data: true, message: '必须有一条数据' },
    value,
    initValues,
    onChange,
  } = props;
  // 查找当前操作的节点
  const finRelationNode = (
    parentData: IFilterValue<T>,
    targetKey: string,
    needCurrent?: boolean,
  ): IFilterValue<T> | null | undefined => {};
  const handleAddCondition = (keyObj: { key: string; isOut?: boolean }) => {};
  // 增加新的数据,判断是在当前节点下新增或者新生成一个条件节点
  const addCondition = (
    treeNode: any,
    keyObj: { key: string; isOut?: boolean },
    initRowValue: T,
  ) => {};
  const handleDeleteCondition = (key: string) => {};
  // 删除节点,删除当前节点下的一条数据或者是删除一个条件节点
  const deleteCondition = (parentData: IFilterValue<T>, key: string) => {};
  // 删除一个条件节点时,更新当前数据的层级
  const updateLevel = (node: IFilterValue<T>) => {};
  // 更改条件节点的条件
  const handleChangeCondition = (
    key: string,
    type: ROW_PERMISSION_RELATION,
  ) => {};
  // 改变节点的的数据
  const handleChangeRowValues = (key: string, values: T) => {};
  return (
    <RulesController<T>
      maxLevel={maxLevel}
      disabled={disabled}
      value={value}
      component={component}
      onAddCondition={handleAddCondition}
      onDeleteCondition={handleDeleteCondition}
      onChangeCondition={handleChangeCondition}
      onChangeRowValues={handleChangeRowValues}
    />
  );
};

编辑情况

非受控组件使用
<Form form={form}>
  <Form.Item name={'condition'}>
    <FilterRules<IRowValue>
      component={(props) => (
        <RowColumnConfig columns={record?.columns ?? []} {...props} />
      )}
      maxLevel={MAX_LEVEL}
      initValues={INIT_ROW_VALUES}
    />
  </Form.Item>
</Form>;

// RowColumnConfig 实现,name 可能是 children[0].formValues
<Form.Item
  name={['condition', ...name, 'column']}
  rules={[{ message: '请选择字段', required: true }]}
  initialValue={column}
>
  <Select placeholder="请选择字段">
    {columns.map((item) => (
      <Option key={item} value={item}>
        {item}
      </Option>
    ))}
  </Select>
</Form.Item>;

// 最后通过 form.validateFields() 拿到的和上述的数据结构一致
受控组件使用
const [ruleData, setRuleData] = useState({
  key: shortid(),
  level: 0,
  rowValues: {
    column: first.column,
    condition: first.condition,
    rowPermission: first?.value,
  },
});

<FilterRules<IRowValue>
  value={ruleData}
  component={(props) => (
    <RowColumnConfig columns={record?.columns ?? []} {...props} />
  )}
  maxLevel={MAX_LEVEL}
  initValues={INIT_ROW_VALUES}
  onChange={setRuleData}
/>;
// 通过 ruleData 就能够拿到最后的结果

查看使用

<FilterRules
  component={(props) => <RowColumnConfig columns={[]} {...props} />}
  disabled
  value={value}
/>

编辑查看使用(后续新增)

file

上图为最后实现的效果,适用于部分数据禁用且可以编辑其他数据。常见业务情景:上一次保存的数据不可修改,但需要在当前基础上继续新增数据。
在这种使用模式下,FilterRules 组件上的 props 依旧为 false,通过设置 value 中每一个节点的 disabled 属性来实现上述功能

// 修改 IFilterValue 的类型
//

前言

在上篇文章中,我们使用WebGL实现了网格背景,当时有提到说使用WebGL来实现的好处之一,是网格背景可以与画布上的其他元素更好地融合,比如一起缩放平移,那么在WebGL中怎么实现缩放和平移呢?现在我们已经实现了网格背景,接下来我们就用网格背景作为例子来了解一下WebGL中的缩放和平移。

这里需要用到我之前学过的变换矩阵,不熟悉变换矩阵的小伙伴可以看我之前的文章
《CSS transform与仿射变换》
,或者查阅其他相关资料;简单来说就是使用矩阵来表示顶点坐标的变换。

具体实现

接下来我们在代码中来具体实现这个效果。

1. 改造顶点着色器

首先我们先改造顶点着色器的GLSL代码,对顶点应用变换矩阵。

attribute vec2 a_vertexPosition;
attribute vec2 uv;

varying vec2 vUv;

uniform int scale;
uniform vec2 offset;

mat3 translateMatrix = mat3( // 平移矩阵
  1.0, 0.0, 0.0, // 第一列
  0.0, 1.0, 0.0, // 第二列
  offset.x, offset.y, 1.0 // 第三列
);

mat3 scaleMatrix = mat3( // 缩放矩阵
  float(scale), 0.0, 0.0,
  0.0, float(scale), 0.0,
  0.0, 0.0, 1.0
);

void main() {
  gl_PointSize = 1.0;
  vUv = uv;
  vec3 pos = scaleMatrix * translateMatrix * vec3(a_vertexPosition, 1.0);
  gl_Position = vec4(pos, 1.0);
}

代码中我们增加接收两个uniform常量
scale

offset
,表示缩放比例和平移的偏移量;并且定义两个矩阵:平移矩阵
translateMatrix
和缩放矩阵
scaleMatrix
。需要注意GLSL这里矩阵的写法是列主序的,也就是说这里的第一行其实是平常我们认为的第一列,也就是说假如有平移矩阵乘以原坐标:
translateMatrix * vec3(a_vertexPosition, 1.0)
,我们会得到如下结果:

\[\begin{bmatrix}
1 & 0 & offset.x \\
0 & 1 & offset.y \\
0 & 0 & 1
\end{bmatrix} \times
\begin{bmatrix}
x_0 \\
y_0 \\
1
\end{bmatrix} =
\begin{bmatrix}
x_0 + offset.x\\
y_0 + offset.y \\
1
\end{bmatrix}
\]

将两个矩阵相乘表示叠加效果,再与坐标向量相乘后,我们就能得到映射的新顶点坐标。

2. 改造js代码

顶点着色器中增加接收的两个参数需要js代码传递过去,接下来我们就对js代码进行改造。

首先是设置这两个变量的初始值,scale等于1表示原始大小,offset设置的初始偏移量为0,也就是不偏移。

// ...
renderer.uniforms.scale = 1;
renderer.uniforms.offset = [0.0, 0.0];
// ...

然后我们添加对鼠标滚轮事件以及鼠标按下和放开事件的监听。

// ...
const addEvent = () => {
  patternPracticeRef.value.addEventListener('mousewheel', wheelEventHandler);
  patternPracticeRef.value.addEventListener('mouseup', mouseUpHandler);
  patternPracticeRef.value.addEventListener('mousedown', mouseDownHandler);
}
addEvent();
// ...

接下来我们就来完成这三个事件监听的回调函数。

我们先来完成滚轮事件的监听回调,这个比较简单。
e.wheelDeltaY > 0
表示滚轮向下滚动,实现放大效果,这里我使用了整数表示放大倍数,这是因为之前我有使用浮点数来实现过,但是存在一些问题,比如绘制出来的网格线会有可能粗细不同,甚至会缺少一些线,这估计是由于精度原因导致的,我还没去进一步的研究。

const wheelEventHandler = e => {
  e.preventDefault();
  if (e.wheelDeltaY > 0) { // 向下滚,放大
    if (renderer.uniforms.scale <= 50) {
      renderer.uniforms.scale += 1;
    }
  } else { // 向上滚,缩小
    if (renderer.uniforms.scale > 1) {
      renderer.uniforms.scale -= 1;
    }
  }
}
const mouseDownHandler = e => {
  // ...
}
const mouseUpHandler = e => {
	// ...
}

此时我们在页面上进行测试一下,就可以看到缩放的效果实现了。
image

接着我们来实现平移的效果,因为偏移量需要根据鼠标的初始位置和移动结束的位置计算得到,所以我们增加一个对象类型的变量lastPos来记录鼠标的初始位置,并在鼠标按下的事件回调中对它赋值。

// ...
const lastPos = {};
// ...
const mouseDownHandler = e => {
  e.preventDefault();
  // 记录初始位置
  lastPos.x = e.offsetX;
  lastPos.y = e.offsetY;
  // 绑定事件
  patternPracticeRef.value.addEventListener('mousemove', mouseMoveHandler);
}

在鼠标按下后,我们对鼠标的移动事件开启监听,便于实时更新位置信息。

const mouseMoveHandler = e => {
  e.preventDefault();
  // 计算坐标差值并转换为Canvas差值
  const { offsetX: x, offsetY: y } = e;
  const translateX = (x - lastPos.x) / patternPracticeRef.value.width;
  const translateY = (lastPos.y - y) / patternPracticeRef.value.height;
  // 设置偏移量
  renderer.uniforms.offset = [translateX, translateY];
}

因为在页面上y轴向下,与WebGL中y轴向上是相反的,所以这里计算y方向的偏移量使用
lastPos.y - y
。我们通过除以canvas的宽高,就能转换得到偏移量在WebGL里的对应数值。

接着我们在鼠标按键松开事件监听的回调中,对移动监听进行移除。

const mouseDownHandler = e => {
   e.preventDefault();
  // 解绑事件
  patternPracticeRef.value.addEventListener('mousemove', mouseMoveHandler);
}

此时在页面上测试,能看到我们可以对网格进行拖动平移了,但很明显这其中还存在问题,当我们完成第一次平移后,想再点击鼠标来平移,就会发现在按下鼠标的那一刻网格会发生闪烁,这是因为在上一次平移完成后,图片的中心点已经改变了,而在GLSL代码中的偏移矩阵中,偏移量的设置是相对
(0, 0)
点的,所以会闪烁一下,也就是说我们需要考虑中心点的影响,在第二次平移时要在新的中心点的基础上去进行平移,在这里我使用一个lastCenter变量来存储中心点的位置。

const lastPos = {}, lastCenter = {};

并在鼠标按键松开的事件回调中对画布中心点的信息进行更新。

const mouseUpHandler = e => {
  e.preventDefault();
  // 计算坐标差值并转换为Canvas差值
  const { offsetX: x, offsetY: y } = e;
  const translateX = (x - lastPos.x) / patternPracticeRef.value.width;
  const translateY = (lastPos.y - y) / patternPracticeRef.value.height;
  // 更新新的中心点
  lastCenter.x = translateX + lastCenter.x;
  lastCenter.y = translateY + lastCenter.y;
  // 解除事件绑定
  patternPracticeRef.value.removeEventListener('mousemove', mouseMoveHandler);
}

同时在鼠标移动过程中偏移量的更新我们也要把中心点的坐标考虑进去。

const mouseMoveHandler = e => {
  e.preventDefault();
  // 计算坐标差值并转换为Canvas差值
  const { offsetX: x, offsetY: y } = e;
  const translateX = (x - lastPos.x) / patternPracticeRef.value.width;
  const translateY = (lastPos.y - y) / patternPracticeRef.value.height;
  // 设置偏移量
  renderer.uniforms.offset = [translateX + lastCenter.x, translateY + lastCenter.y];
}

这个时候我们再去测试一下,可以看到就是正常的了。

总结

到这里为止我们就实现了在WebGL中的缩放和平移,这里主要就是借助了鼠标事件的监听和变换矩阵的应用。

完整代码
最终效果

一.引言

在当前的移动开发生态中,跨平台框架如uni-app因其高效、灵活的特点受到了开发者们的青睐。同时,随着物联网技术的飞速发展,智能打印设备已成为许多业务场景中不可或缺的一环。今天,我们就来探讨如何使用uni-app轻松对接驰腾品牌的智能打印机,实现无线打印功能。无论您是初学者还是有经验的开发者,本教程都将带您一步步实现这一目标。

二.准备工作

首先确保您的开发环境已就绪。这包括安装HBuilderX和uni-app框架。同时,您需要准备一台驰腾打印机,并熟悉其用户手册和API文档。了解打印机支持的通信协议(比如蓝牙或Wi-Fi)也至关重要。

三.对接流程解析

在进行代码编写之前,我们需要理解整个接口调用的流程。这通常包括建立与打印机的连接、发送打印指令以及处理返回结果。此外,我们还需要关注数据格式、编码要求以及安全机制。

四.详细步骤与实施

1.设备连接与通讯建立

蓝牙连接流程

  1. 使用uni-app提供的蓝牙模块初始化并搜索打印机设备。
  2. 配对并连接到驰腾打印机。

2.发送打印指令

  1. 数据封装与传输
    • 依据驰腾打印机的API文档,正确封装打印数据。
    • 调用相关API发送打印任务。
  2. 错误处理与反馈
    • 监听打印状态变化,及时响应可能出现的错误。
    • 向用户提供清晰的状态反馈信息。

3.打印状态展示

  • 实时显示当前打印任务的状态,包括成功、等待和失败等。

五.代码实例与详解

前期准备:

需要下载一个js文件支持包,请先点击下载

点击下载js支持包

点击下载开发指南

开发说明中有详细的指令文档,需要的大家可以自行翻阅

以下提供一个使用打印机进行二维码打印的经典案例

1.先将js包解压,并在项目中创建文件夹保存

2.现在需要两个页面,一个负责蓝牙搜索和连接,一个复制连接后的打印工作
测试蓝牙连接页面代码:

<template>
  <view class="container">
	  <view class="top-box">
		  <view class="name">打印机搜索</view>
		  <view class="value" @click="onLoadFun" v-if="submitMain">
			  点击搜索
		  </view>
		 <!-- <view class="value" @click="rescan" v-else>
			  重新搜索
		  </view> -->
	  </view>
    <scroll-view scroll-y class="box">
      <view
        class="item"
        v-for="(item, index) in blueDeviceList"
        :key="index"
        @click="connect(item, index)"
        :class="{ active: blueIndex === index }"
      >
        <view>
          <text>{{ item.name }}</text>
        </view>
        <view>
          <text>{{ item.deviceId }}</text>
        </view>
      </view>
	 <!-- <view class="item">{{code}}</view> -->
    </scroll-view>
  </view>
</template>

<script>
import CTPL from "@/web-CTPL-SDK/ctpl.js";
export default {
  data() {
    return {
      blueDeviceList: [], //蓝牙设备列表
      blueName: "", //蓝牙设备名称
      blueDeviceId: "", //蓝牙设备特征值
      blueIndex: -1,
      submitMain:true
    };
  },
  onUnload() {
  	if(this.blueDeviceId){
		CTPL.disconnect();
	}
  },
  methods: {
	async onLoadFun(){
		await CTPL.init();
		this.submitMain = false;
		await this.discoveryList()
	},
    clickLeft() {
      uni.navigateBack();
    },
    async discoveryList() {
		
      uni.showLoading({
		  title:'搜索设备中'
	  });
       CTPL.discovery().then((res)=>{
		    uni.hideLoading();
		    this.blueDeviceList = res;
	   }).catch((err)=>{
		    uni.hideLoading();
			uni.$u.toast(err)
	   })
    },
    //重新扫描
    rescan() {
      this.blueDeviceList = [];
      this.discoveryList();
    },
    //开始连接蓝牙
    connect(data, index) {
		const that = this;
		uni.showModal({
			title:'温馨提示',
			content:'是否使用选中设备进行打印',
			success(res) {
				if(res.confirm){
					CTPL.connect(data.deviceId);
					that.blueIndex = index;
					that.blueDeviceId = data.deviceId;
					that.blueName = data.name;
					setTimeout(() => {
						
						uni.showLoading({
							title:'配置设备中'
						})
					   that.setCodeFun()
					}, 1000);
				}
			}
		})
    },
	setCodeFun(){
		const that = this;
		CTPL.setPaperType(0);
		setTimeout(()=>{
			CTPL.setMemoryPrint(0);
			uni.hideLoading()
			setTimeout(()=>{
				uni.navigateTo({
				  url: `要进行打印的页面?id=${that.orderId}&deviceId=${that.blueDeviceId}`,
				});
			},500)
		},500)
	},

  },
};
</script>

<style lang="scss" scoped>
.container {
  width: 100%;
  overflow: hidden;
  min-height: 100vh;
}
.top-box{
	width: 100%;
	padding: 30rpx;
	background-color: white;
	color: #000000;
	line-height: 70rpx;
	font-size: 32rpx;
	overflow: hidden;
	.name{
		width: 50%;
		display: inline-block;
		vertical-align: top;
	}
	.value{
		width: 30%;
		float: right;
		display: inline-block;
		vertical-align: top;
		background:#009180;
		color: white;
		text-align: center;
		border-radius: 10rpx;
	}
}

$nav-height: 30px;

.box-bg {
  background-color: #f5f5f5;
  .nav {
    text {
      font-size: 28rpx !important;
    }
    .uni-nav-bar-right-text {
      color: #1aad19 !important;
    }
  }
}

.city {
  /* #ifndef APP-PLUS-NVUE */
  display: flex;
  /* #endif */
  flex-direction: row;
  align-items: center;
  justify-content: flex-start;
  // width: 160rpx;
  margin-left: 4px;
}

.input-view {
  /* #ifndef APP-PLUS-NVUE */
  display: flex;
  /* #endif */
  flex-direction: row;
  // width: 500rpx;
  flex: 1;
  background-color: #f8f8f8;
  height: $nav-height;
  border-radius: 15px;
  padding: 0 15px;
  flex-wrap: nowrap;
  margin: 7px 0;
  line-height: $nav-height;
}

.input-uni-icon {
  line-height: $nav-height;
}

.nav-bar-input {
  height: $nav-height;
  line-height: $nav-height;
  /* #ifdef APP-PLUS-NVUE */
  width: 370rpx;
  /* #endif */
  padding: 0 5px;
  font-size: 14px;
  background-color: #f8f8f8;
}

.box {
  height: calc(100vh - 100px);
  overflow: hidden;
}

.item {
  height: 120rpx;
  border-bottom: 1px solid #e5e5e5;
  background-color: white;
  width: 700rpx;
  margin: 26rpx auto 0 auto;
  overflow: hidden;
  font-size: 28rpx;
  line-height: 120rpx;
  padding: 0 20rpx;
  border-radius: 10rpx;
}

.active {
  background-color: #1aad19;
  color: white;
}
</style>    

注意点:连接了设备后,除非断开并关闭小程序,不然不要重新连接,会直接卡死

测试打印页面代码(核心打印代码):

数据:

        mainCodeArr:[],
		qrcodeObj: {
			x: 100,
			y: 70,
			eccLevel: "H",
			cellWidth: 6,
			encodeMode: "A",
			rotation: 0,
			codeMode: "M1",
			mask: "S7",
			content: 1234567890,
		},
		textObj: {
			x: "80",
			y: "20",
			rotation: "0",
			xRatio: "1",
			yRatio: "1",
			textAlignment: "0",
			text: "我的测试商品(1)"
		},
		code:''

调用方法:

 async setCodeIndex(index){
	  	uni.showLoading({
	  		title:'打印中'
	  	})
	  	const item = this.mainCodeArr[index]
	  	CTPL.queryPrintMode(0);
	  	CTPL.setSize(4,3);
	  	CTPL.clearCache();
	  	let code =  item.code;
	  	this.code = code;
	  	setTimeout(()=>{
	  		CTPL.drawQRCode(
	  			this.qrcodeObj.x,
	  			this.qrcodeObj.y,
	  			this.qrcodeObj.eccLevel,
	  			this.qrcodeObj.cellWidth,
	  			this.qrcodeObj.encodeMode,
	  			this.qrcodeObj.rotation,
	  			this.qrcodeObj.codeMode,
	  			this.qrcodeObj.mask,
	  			code
	  		);
	  		setTimeout(()=>{
	  			let left = 40;
	  			if(item.product_title.length < 9){
	  				left += ((10 - item.product_title.length) * 10)
	  			}else{
	  				item.product_title = item.product_title.slice(0,9) +'...'
	  			}
	  			// 绘制条码
	  			CTPL.drawText(
	  				left,
	  				this.textObj.y,
	  				this.textObj.rotation,
	  				this.textObj.xRatio,
	  				this.textObj.yRatio,
	  				this.textObj.textAlignment,
	  				(item.product_title+'('+item.index+')')
	  			);
	  			setTimeout(()=>{
	  				CTPL.setPrintCopies(1, 1);
	  				CTPL.execute();
	  				uni.hideLoading()
	  				if(this.mainCodeArr.length != index +1){
	  					setTimeout(()=>{
	  						this.setCodeIndex(index +1)
	  					},500)
	  					
	  				}else{
	  					uni.showModal({
	  						title:'温馨提示',
	  						content:'打印完成',
	  						showCancel:false
	  					})
	  				}
	  			},1000)
	  		},500)
	  	},1000)
	  },

调用代码:

this.setCodeIndex(0)

总结

以上的一些步骤和代码案例,就是我对接驰腾打印机的全流程,核心主要在:1.设备连接与通讯建立,2.发送打印指令,3.打印状态显示和真机调试,希望我的一点经验能对你有用

如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。