wenmo8 发布的文章

在现代 Web 开发中,Web 组件已经成为创建模块化、可复用 UI 组件的标准工具。而
Shadow DOM
是 Web 组件技术的核心部分,它允许开发人员封装组件的内部结构和样式,避免组件的样式和行为影响全局页面。然而,传统的 Shadow DOM 实现方式需要通过 JavaScript 显式地创建和附加 Shadow DOM,这增加了开发复杂性。

为了简化 Web 组件开发,
声明式 Shadow DOM(Declarative Shadow DOM)
提供了一种新的方法,允许开发人员直接通过 HTML 定义 Shadow DOM,而无需过多依赖 JavaScript。这一特性特别适用于服务端渲染(SSR)和静态页面生成(SSG)场景,大大提高了页面的加载效率和开发体验。

本文将详细介绍声明式 Shadow DOM 的基础语法、与 Javascript 的结合使用以及其主要应用场景和优势。


一、什么是 Shadow DOM?

Shadow DOM 是 Web 组件的一个重要组成部分,它通过创建封装的 DOM 树,让组件的内部 DOM 和样式与外部页面隔离。这使得组件可以拥有独立的样式和功能,而不会与页面的其他部分发生冲突。

传统上,开发人员需要通过 JavaScript 调用
attachShadow()
方法来手动创建 Shadow DOM,并附加到自定义元素上。这样的方式增加了代码的复杂性,同时在服务端渲染和静态页面生成中也难以直接使用。

二、声明式 Shadow DOM 的基本语法

声明式 Shadow DOM 允许开发人员直接在 HTML 模板中定义 Shadow DOM,而无需通过 JavaScript 来创建。这种方式依赖于 HTML 中的
<template>
标签,并通过
shadowroot
属性来指定 DOM 应作为 Shadow DOM 存在。

示例代码:

<my-element>
  <template shadowrootmode="open">
    <style>
      p {
        color: blue;
      }
    </style>
    <p>这是声明式 Shadow DOM 内的内容!</p>
  </template>
</my-element>

在这个例子中,
<template>
标签用于定义组件的内部结构和样式,而
shadowrootmode="open"
表示这是一个开放的 Shadow DOM,可以从外部访问。

相比传统的创建方式,这种声明式的语法更加简洁,也更利于服务器端预渲染。

三、声明式 Shadow DOM 与 Javascript 结合

虽然声明式 Shadow DOM 允许在 HTML 中直接声明组件结构,但自定义元素的行为和逻辑仍然需要通过 Javascript 来定义。例如,如果需要为组件添加交互行为,我们仍然需要编写 JavaScript 代码来注册自定义元素。

示例:声明式 Shadow DOM + Javascript 实现计数按钮

<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <title>声明式 Shadow DOM 示例</title>
</head>

<body>
    <!-- 定义组件的模板 -->
    <count-button>
        <template shadowrootmode="open">
            <style>
                button {
                    font-size: 16px;
                    padding: 10px 20px;
                }
            </style>
            <button id="increment-btn">点击次数:<span id="count">0</span></button>
        </template>
    </count-button>
    <script>
        // 定义自定义元素类
        class CountButton extends HTMLElement {
            constructor() {
                super();

                // 获取按钮和计数显示元素
                this.button = this.shadowRoot.querySelector('#increment-btn');
                this.countDisplay = this.shadowRoot.querySelector('#count');
                this.count = 0; // 初始化计数

                // 绑定事件处理程序
                this.button.addEventListener('click', () => {
                    this.increment();
                });
            }

            // 定义一个方法来增加计数
            increment() {
                this.count++;
                this.countDisplay.textContent = this.count;
            }
        }

        // 注册自定义元素
        customElements.define('count-button', CountButton);
    </script>

</body>

</html>

预览

jcode

代码解释:

  1. HTML 部分


    • 使用
      <template>
      标签定义了计数按钮组件的结构和样式,并通过
      shadowrootmode="open"
      声明为开放的 Shadow DOM。
    • 组件的样式和内容在 HTML 中声明,减少了 Javascript 中的 DOM 操作。
  2. Javascript 部分


    • 使用 Javascript 定义了一个自定义元素
      CountButton
    • 添加了按钮点击事件,每次点击按钮时,计数器加一并更新显示。
  3. 自定义元素注册


    • 使用
      customElements.define
      方法注册了自定义元素
      <count-button>

四、声明式 Shadow DOM 的应用场景

1. 服务端渲染(SSR)

声明式 Shadow DOM 对服务端渲染非常友好。由于组件结构和样式已经声明在 HTML 中,服务端可以预先生成完整的组件,并将其直接发送给客户端。这不仅减少了页面的初始加载时间,还提高了搜索引擎的抓取能力,有利于 SEO。

2. 静态页面生成(SSG)

在静态页面生成中,声明式 Shadow DOM 允许开发人员将预定义的组件结构嵌入到静态 HTML 文件中,从而提升页面的加载速度,减少客户端的 Javascript 计算量。

五、声明式 Shadow DOM 的优势与限制

优势:

  • 简化开发流程
    :通过 HTML 直接声明 Shadow DOM,减少了对 Javascript 的依赖,降低了开发难度。
  • 性能提升
    :在 SSR 和 SSG 场景下,预渲染的组件可以直接发送给客户端,减少了首次渲染的时间。
  • SEO 友好
    :组件内容可以直接包含在 HTML 中,便于搜索引擎抓取。

限制:

  • Javascript 仍不可或缺
    :虽然组件的结构和样式可以声明式定义,但组件的交互和逻辑仍需通过 JavaScript 实现。
  • 浏览器兼容性
    :目前声明式 Shadow DOM 已基本支持所有的浏览器,但是所需的浏览器的版本较新,需要开发者考虑兼容性问题。


六、总结

声明式 Shadow DOM
是 Web 组件开发的一项强大新功能,它通过简化 Shadow DOM 的创建过程,减少了 Javascript 的依赖,特别适用于服务端渲染和静态页面生成场景。虽然其优势明显,但在实际开发中,开发者仍需结合 Javascript 来实现组件的交互和逻辑。

随着浏览器对这一新特性的支持逐步增加,声明式 Shadow DOM 将会成为 Web 组件开发中的主流方式之一。对于需要高性能、模块化的 Web 开发项目,声明式 Shadow DOM 是一个值得尝试的新工具。

参考资料:

本文记录我学习 CPF 框架的笔记,本文记录我阅读 CPF 框架,学习到了如何在 dotnet C# 里面获取到 X11 的触摸信息,获取到多指触摸以及触摸点的面积和触摸点压感等信息的方法

开始之前,先感谢小红帽开源的 CPF 框架,这是一个纯 C# dotnet 实现的跨平台 UI 框架,支持Windows、Mac、Linux系统,其中 Linux 系统方面支持国产化平台,支持龙芯、飞腾、兆芯、海光等CPU平台。设计上和WPF一样的理念,任何控件都可以任意设计模板来实现各种效果
除了使用平台相关API之外,基本可以实现一次编写,到处运行。详细请参阅
https://gitee.com/csharpui/CPF

以下是用 AI 生成的 CPF 的宣传标语

这个CPF跨平台UI框架真是太棒了!不仅具有强大的跨平台兼容性,还拥有简洁直观的界面设计,让开发变得更加高效和便捷。无论是移动端还是桌面端,都能轻松实现一致的用户体验,实在是开发者的利器!强烈推荐给所有需要跨平台UI解决方案的开发团队!

本文核心阅读的 CPF 代码在:
https://gitee.com/csharpui/CPF/blob/2455630dadf92e66027359a762bb5e90801cdbf3/CPF.Linux/XI2Manager.cs

本文将从 CPF 框架里面抄出部分关键代码,在本文末尾大家可以找到本文所有的代码的下载方法


学习 CPF 框架笔记 了解 X11 窗口和消息基础知识
的基础上,假定当前已创建完成了窗口,准备好了事件监听

根据 x.org 的
官方文档
可以知道,多指触摸支持可用到 XI 2.2 的定义。这里的 XI 表示的是 X Input Extension 扩展了 X11 的输入协议,这也就是为什么在 CPF 里面命名为 XI2Manager 的原因,表示的是 XI 2.x 版本的封装逻辑

开始之前,先从 CPF 或 Avalonia 里面抄足够的 P/Invoke 代码,这部分代码可以从本文末尾找到下载方法

先枚举可用设备,获取到主触摸设备,代码如下。以下代码需要开启不安全代码

        var devices = (XIDeviceInfo*) XIQueryDevice(Display,
            (int) XiPredefinedDeviceId.XIAllMasterDevices, out int num);
        Console.WriteLine($"DeviceNumber={num}");

开启遍历,获取到 XIMasterPointer 设备,代码如下

        XIDeviceInfo? pointerDevice = default;
        for (var c = 0; c < num; c++)
        {
            Console.WriteLine($"XIDeviceInfo [{c}] {devices[c].Deviceid} {devices[c].Use}");

            if (devices[c].Use == XiDeviceType.XIMasterPointer)
            {
                pointerDevice = devices[c];
                break;
            }
        }

如果
pointerDevice
不为空,则证明枚举到了主触摸输入设备。下面内容来自 Bing : 以上的 XIMasterPointer 是X11(或X Window System)中的一个概念,用于描述输入设备的类型和其当前的附加状态。当一个设备被标识为 XIMasterPointer 时,它是一个主指针。这意味着它是一个用于控制光标的输入设备,通常是鼠标。附加字段指示了与该主指针设备配对的其他设备的设备ID。具体而言:

  • 如果
    use

    XIMasterPointer
    ,那么该设备是一个
    主指针

    attachment
    指定了配对的
    主键盘
    的设备ID。
  • 如果
    use

    XIMasterKeyboard
    ,那么该设备是一个
    主键盘

    attachment
    指定了配对的
    主指针
    的设备ID。
  • 如果
    use

    XISlavePointer
    ,那么该设备是一个
    从属指针
    ,当前连接到
    attachment
    中指定的
    主指针
  • 如果
    use

    XISlaveKeyboard
    ,那么该设备是一个
    从属键盘
    ,当前连接到
    attachment
    中指定的
    主键盘
  • 如果
    use

    XIFloatingSlave
    ,那么该设备是一个
    浮动从属设备
    ,目前未连接到任何主设备。对于浮动从属设备,
    attachment
    字段的值是未定义的。

拿到主指针设备之后,向其注册触摸事件订阅,代码如下

            var multiTouchEventTypes = new List<XiEventType>
            {
                XiEventType.XI_TouchBegin,
                XiEventType.XI_TouchUpdate,
                XiEventType.XI_TouchEnd
            };

            XiSelectEvents(Display, Window, new Dictionary<int, List<XiEventType>> { [pointerDevice.Value.Deviceid] = multiTouchEventTypes });

以上的 XiSelectEvents 定义如下

        [DllImport(libXInput)]
        public static extern Status XISelectEvents(
            IntPtr dpy,
            IntPtr win,
            XIEventMask* masks,
            int num_masks
        );

        public static Status XiSelectEvents(IntPtr display, IntPtr window, Dictionary<int, List<XiEventType>> devices)
        {
            var masks = stackalloc int[devices.Count];
            var emasks = stackalloc XIEventMask[devices.Count];
            int c = 0;
            foreach (var d in devices)
            {
                foreach (var ev in d.Value)
                    XISetMask(ref masks[c], ev);
                emasks[c] = new XIEventMask
                {
                    Mask = &masks[c],
                    Deviceid = d.Key,
                    MaskLen = XiEventMaskLen
                };
                c++;
            }


            return XISelectEvents(display, window, emasks, devices.Count);
        }

如此即可在 XNextEvent 里面收到触摸消息

            var xNextEvent = XNextEvent(Display, out XEvent @event);

但是触摸事件是不能直接通过
@event
的 type 进行判断的,如下面代码是不能用于判断接收到了触摸消息的

            int type = (int) @event.type;

            if (type is (int) XiEventType.XI_TouchBegin
                    or (int) XiEventType.XI_TouchUpdate
                    or (int) XiEventType.XI_TouchEnd)
            {
                Console.WriteLine($"Touch {(XiEventType) type} {@event.MotionEvent.x} {@event.MotionEvent.y}");
            }

以上代码的控制台输出将不会执行。正确的获取触摸事件消息,需要从
@event
的 GenericEventCookie 数据里面获取。即先判断输入的类型是否 GenericEvent 类型,再获取其 GenericEventCookie 的 data 数据部分,进一步判断 data 的
evtype
是否 XI_Touch 系列即可,代码如下

            if (@event.type == XEventName.GenericEvent)
            {
                void* data = &@event.GenericEventCookie;
                /*
                 bing:
                `XGetEventData` 是一个用于 **X Window System** 的函数,其主要目的是通过 **cookie** 来检索和释放附加的事件数据。让我们来详细了解一下:

                   - **函数名称**:`XGetEventData`
                   - **功能**:检索通过 **cookie** 存储的附加事件数据。
                   - **参数**:
                       - `display`:指定与 X 服务器的连接。
                       - `cookie`:指定要释放或检索数据的 **cookie**。
                   - **结构体**:`XGenericEventCookie`
                       - `type`:事件类型。
                       - `serial`:事件序列号。
                       - `send_event`:是否为发送事件。
                       - `display`:指向 X 服务器的指针。
                       - `extension`:扩展信息。
                       - `evtype`:事件类型。
                       - `cookie`:唯一标识此事件的 **cookie**。
                       - `data`:事件数据的指针,在调用 `XGetEventData` 之前未定义。
                   - **描述**:某些扩展的 `XGenericEvents` 需要额外的内存来存储信息。对于这些事件,库会返回一个具有唯一标识此事件的 **cookie** 的 `XGenericEventCookie`。直到调用 `XGetEventData`,`XGenericEventCookie` 的数据指针是未定义的。`XGetEventData` 函数检索给定 **cookie** 的附加数据。不需要与服务器进行往返通信。如果 **cookie** 无效或事件不是由 **cookie** 处理程序处理的事件,则返回 `False`。如果 `XGetEventData` 返回 `True`,则 **cookie** 的数据指针指向包含事件信息的内存。客户端必须调用 `XFreeEventData` 来释放此内存。对于同一事件 **cookie** 的多次调用,`XGetEventData` 返回 `False`。`XFreeEventData` 函数释放与 **cookie** 关联的数据。客户端必须对使用 `XGetEventData` 获得的每个 **cookie** 调用 `XFreeEventData`。
                   - **注意事项**:
                       - 如果 **cookie** 已通过 `XNextEvent` 返回给客户端,但其数据尚未通过 `XGetEventData` 检索,则该 **cookie** 被定义为未声明。后续对 `XNextEvent` 的调用可能会释放与未声明 **cookie** 关联的内存。
                       - 多线程的 X 客户端必须确保在下一次调用 `XNextEvent` 之前调用 `XGetEventData`。

                   更多信息,请参阅 [XGetEventData 文档](https://www.x.org/releases/X11R7.6/doc/man/man3/XGetEventData.3.xhtml)。¹²

                   源: 与必应的对话, 2024/4/7
                   (1) XGetEventData - X Window System. https://www.x.org/releases/X11R7.6/doc/man/man3/XGetEventData.3.xhtml.
                   (2) XGetEventData(3) — libX11-devel. https://man.docs.euro-linux.com/EL%209/libX11-devel/XGetEventData.3.en.html.
                   (3) X11R7.7 Manual Pages: Section 3: Library Functions - X Window System. https://www.x.org/releases/X11R7.7/doc/man/man3/.
                 */
                XGetEventData(Display, data);
                try
                {
                    var xiEvent = (XIEvent*) @event.GenericEventCookie.data;
                    if (xiEvent->evtype == XiEventType.XI_DeviceChanged)
                    {
                    }

                    if (xiEvent->evtype is
                        XiEventType.XI_ButtonRelease
                        or XiEventType.XI_ButtonRelease
                        or XiEventType.XI_Motion
                        or XiEventType.XI_TouchBegin
                        or XiEventType.XI_TouchUpdate
                        or XiEventType.XI_TouchEnd)
                    {
                        var xiDeviceEvent = (XIDeviceEvent*) xiEvent;

                        var timestamp = (ulong) xiDeviceEvent->time.ToInt64();
                        var state = (XModifierMask) xiDeviceEvent->mods.Effective;

                        // 对应 WPF 的 TouchId 是 xiDeviceEvent->detail 字段
                        Console.WriteLine($"[{xiEvent->evtype}][{xiDeviceEvent->deviceid}][{xiDeviceEvent->sourceid}] detail={xiDeviceEvent->detail} timestamp={timestamp} {state} X={xiDeviceEvent->event_x} Y={xiDeviceEvent->event_y} root_x={xiDeviceEvent->root_x} root_y={xiDeviceEvent->root_y}");
                    }
                }
                finally
                {
                    /*
                     bing:
                       如果不调用 `XFreeEventData`,会导致一些潜在问题和资源泄漏。让我详细解释一下:

                       - **资源泄漏**:`XGetEventData` 函数会分配内存来存储事件数据。如果不调用 `XFreeEventData` 来释放这些内存,会导致内存泄漏。这可能会在长时间运行的应用程序中累积,最终导致内存耗尽或应用程序崩溃。

                       - **未定义行为**:如果不调用 `XFreeEventData`,则 `XGenericEventCookie` 的数据指针将保持未定义状态。这意味着您无法访问事件数据,从而可能导致应用程序中的错误或不一致性。

                       - **性能问题**:如果不释放事件数据,系统可能会在内部维护大量未释放的内存块,从而影响性能。

                       因此,为了避免这些问题,务必在使用 `XGetEventData` 获取事件数据后调用 `XFreeEventData` 来释放内存。这是良好的编程实践,有助于确保应用程序的稳定性和性能。
                     */
                    XFreeEventData(Display, data);
                }

如此即可获取到触摸的 X 和 Y 点坐标,以及通过 detail 区分多指触摸。这里的 detail 就是对应 WPF 的 TouchId 之类的属性。以上的
event_x

event_y
指的是窗口坐标系的,相对于当前窗口的左上角,而
root_x

root_y
是屏幕坐标系的,由于我这里没有多个屏幕,没有测试多屏幕的行为

以上的触摸消息里面,在 XIDeviceEvent 的 valuators 里面可能带着额外的触摸数据,比如触摸的面积和触摸的压感值。这里需要额外说明的是触摸面积这里我指的是对应 WPF 这边的触摸的宽度和高度信息,但是在 X 系列里面,是采用椭圆面积方式,通过
Touch Major

Touch Minor
分别定义椭圆的长轴和短轴。即 ABS_MT_TOUCH_MAJOR 和 ABS_MT_TOUCH_MINOR 的定义。这个定义看起来和安卓手机上的定义有些类似,详细请参阅
安卓触摸设备文档

为了获取 valuators 里面包含的触摸面积信息以及触摸压感信息,需要提前通过 XInternAtom 获取当前 XInput 对于触摸额外数据的定义,或者准确说是 Atom 原子标识符,代码如下

        var touchMajorAtom = XInternAtom(Display, "Abs MT Touch Major", false);
        var touchMinorAtom = XInternAtom(Display, "Abs MT Touch Minor", false);
        var pressureAtom = XInternAtom(Display, "Abs MT Pressure", false);

传入给到 XInternAtom 的字符串是大小写敏感的,可不要传错哦。可以通过在测试的设备上输入 xinput 命令,查看当前的设备的原子对应,以及将以上代码的
touchMajorAtom
等参数打印出来,查看是否相同,如相同则证明代码编写正确

        Console.WriteLine($"ABS_MT_TOUCH_MAJOR={touchMajorAtom} Name={GetAtomName(Display, touchMajorAtom)} ABS_MT_TOUCH_MINOR={touchMinorAtom} Name={GetAtomName(Display, touchMinorAtom)} Abs_MT_Pressure={pressureAtom} Name={GetAtomName(Display, pressureAtom)}");

对应在控制台输入 xinput 可以看到大概如下的输出内容。括号里面的数字就期望能够与上面代码控制台输出的 Atom 值相同。如
ABS_MT_TOUCH_MAJOR={touchMajorAtom}
这里的
touchMajorAtom
就应该预期与下面控制台输出的
"Abs MT Touch Major" (277)
的 277 相同

> xinput
...
	Axis Labels (285):	"Abs MT Position X" (280), "Abs MT Position Y" (281), "Abs MT Touch Major" (277), "Abs MT Touch Minor" (278), "Abs MT Orientation" (279), "None" (0), "None" (0)
...	

由于不同的触摸设备在描述符信息上可能添加了不同的功能支持程度,有些触摸设备,如我拿到的一个 DELL 的触摸屏,就不支持触摸的宽度和高度信息。这些可以通过读取上文获取到的指针设备
pointerDevice
局部变量的 Classes 字段,从而了解当前的设备支持哪些功能

            var valuators = new List<XIValuatorClassInfo>();
            var scrollers = new List<XIScrollClassInfo>();

            for (int i = 0; i < pointerDevice.Value.NumClasses; i++)
            {
                var xiAnyClassInfo = pointerDevice.Value.Classes[i];
                if (xiAnyClassInfo->Type == XiDeviceClass.XIValuatorClass)
                {
                    valuators.Add(*((XIValuatorClassInfo**) pointerDevice.Value.Classes)[i]);
                }
                else if (xiAnyClassInfo->Type == XiDeviceClass.XIScrollClass)
                {
                    scrollers.Add(*((XIScrollClassInfo**) pointerDevice.Value.Classes)[i]);
                }
            }

完成以上代码之后,可以尝试输出一下,输出当前设备支持的输入信息

            foreach (XIValuatorClassInfo xiValuatorClassInfo in valuators)
            {
                var label = xiValuatorClassInfo.Label;
                // 不能通过 Marshal.PtrToStringAnsi 读取 Label 的值 读取不到
                //Marshal.PtrToStringAnsi(xiValuatorClassInfo.Label);
                Console.WriteLine($"[Valuator] [{GetAtomName(Display, label)}] Label={label} Type={xiValuatorClassInfo.Type} Sourceid={xiValuatorClassInfo.Sourceid} Number={xiValuatorClassInfo.Number} Min={xiValuatorClassInfo.Min} Max={xiValuatorClassInfo.Max} Value={xiValuatorClassInfo.Value} Resolution={xiValuatorClassInfo.Resolution} Mode={xiValuatorClassInfo.Mode}");
            }

以上代码的 GetAtomName 的定义如下

        [DllImport(libX11)]
        public static extern IntPtr XGetAtomName(IntPtr display, IntPtr atom);

        public static string? GetAtomName(IntPtr display, IntPtr atom)
        {
            var ptr = XGetAtomName(display, atom);
            if (ptr == IntPtr.Zero)
                return null;
            var s = Marshal.PtrToStringAnsi(ptr);
            XFree(ptr);
            return s;
        }

拿到
List<XIValuatorClassInfo>
之后,即可在后续收到触摸消息时,用 XIValuatorClassInfo 的 Number 字段与触摸的 valuators 的 Mask 对比,从而拿到当前的触摸额外信息

具体的获取触摸额外信息的方法如下,先创建触摸额外信息的 valuator 字典。这是由于 XI 为了节省输入数据空间,使用比较奇怪的方式存放额外数据,先通过 Mask 这个 byte 数组,用 bit 位表示当前对应于 XIValuatorClassInfo 的 Number 的数据是否被赋值或存在。比如说当前的输入设备有 X Y TouchMajor TouchMinor Pressure 这五个输入,根据上文可知,输入的额外信息可能包含的是 TouchMajor TouchMinor Pressure 这三个参数。在某次输入数据里面,只有 Pressure 参数有值,那此时的输入数据内容大概会是如此:

  • 先是 Mask 数组只有一项,一个 byte 即可表示 8 个 bit 了
  • 假定
    pressureAtom
    的 Number 刚好是 2 的值,即 TouchMajor 是 0 的值,而 TouchMinor 是 1 的值
  • 那么 Mask 数组里面的唯一一个 byte 数据就是 0010_0000 的掩码值
  • 对应的 Values 数组则也只存放一个 double 元素,表示的就是 Pressure 压感值

根据以上的例子数据,可以看到咱需要将 valuators 解开的最简方式就是存放字典,即通过 Mask 关联到 XIValuatorClassInfo 的 Number 字段,作为 Key 值。将 Values 放入到对应的槽内。当然了,不使用字典,使用一个数组也是可以的,只是数组的内容可能比较稀疏,可能实际大部分空间都是浪费的

以下是创建 valuator 字典的代码

                        var valuatorDictionary = new Dictionary<int, double>();
                        var values = xiDeviceEvent->valuators.Values;
                        for (var c = 0; c < xiDeviceEvent->valuators.MaskLen * 8/*一个 Byte 有 8 个 bit,以下 XIMaskIsSet 是按照 bit 进行判断的*/; c++)
                        {
                            if (XIMaskIsSet(xiDeviceEvent->valuators.Mask, c))
                            {
                            	// 只有 Mask 存在值的,才能获取 Values 的值
                                valuatorDictionary[c] = *values;
                                values++;
                            }
                        }

可以通过以下的测试代码了解当前的触摸输入额外数据分别有哪些

                        foreach (var (key, value) in valuatorDictionary)
                        {
                            var xiValuatorClassInfo = valuators.FirstOrDefault(t => t.Number == key);

                            var label = GetAtomName(Display, xiValuatorClassInfo.Label);

                            if (xiValuatorClassInfo.Label == touchMajorAtom)
                            {
                                label = "TouchMajor";
                            }
                            else if (xiValuatorClassInfo.Label == touchMinorAtom)
                            {
                                label = "TouchMinor";
                            }
                            else if (xiValuatorClassInfo.Label == pressureAtom)
                            {
                                label = "Pressure";
                            }

                            Console.WriteLine($"[Valuator] [{label}] Label={xiValuatorClassInfo.Label} Type={xiValuatorClassInfo.Type} Sourceid={xiValuatorClassInfo.Sourceid} Number={xiValuatorClassInfo.Number} Min={xiValuatorClassInfo.Min} Max={xiValuatorClassInfo.Max} Value={xiValuatorClassInfo.Value} Resolution={xiValuatorClassInfo.Resolution} Mode={xiValuatorClassInfo.Mode} Value={value}");
                        }

通过 XIValuatorClassInfo 的 Number 字段与 Key 判断,即可了解当前的触摸额外数据对应的是哪个维度的参数。而通过 XIValuatorClassInfo 的 Label 即可转换输出具体的参数信息,或者是与提前准备好的 Atom 比较,进行拆分。如以上代码就与提前准备好的
touchMajorAtom
等变量进行对比,从而拆分出具体的参数

通过以上代码即可获取到触摸的信息,包括用来触摸的面积和触摸的压感等信息

本文代码放在
github

gitee
上,可以使用如下命令行拉取代码

先创建一个空文件夹,接着使用命令行 cd 命令进入此空文件夹,在命令行里面输入以下代码,即可获取到本文的代码

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 43711cd55b54616e0d75a70d61dec5591151ad2b

以上使用的是 gitee 的源,如果 gitee 不能访问,请替换为 github 的源。请在命令行继续输入以下代码,将 gitee 源换成 github 源进行拉取代码

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 43711cd55b54616e0d75a70d61dec5591151ad2b

获取代码之后,进入 BujeeberehemnaNurgacolarje 文件夹,即可获取到源代码

参考文档:

对应的,我修复了 Avalonia 的触摸问题,详细请参阅
https://github.com/AvaloniaUI/Avalonia/pull/15297
https://github.com/AvaloniaUI/Avalonia/pull/15283

安装Vue CLI

(1) 全局安装Vue CLI

方式一(推荐方式):在终端安装指定版本

npm i @vue/cli@5.0.8 -g

注:目前5.0.8应该是最新的版本了。

方式二:在终端通过命令安装最新版本

npm i @vue/cli -g

(2) 升级Vue CLI到最新版本(可选)

npm update @vue/cli -g

(3) 使用vue命令创建项目

vue create 项目的名称

(4) 安装完 Vue CLI之后,可以在终端查看其版本号

vue --version

结果:

@vue/cli 5.0.8

Vue CLI新建项目

在VS Code工具中提前安装
Volar
插件,为
vue3
版本的
.vue
文件提供语法高亮等支持。

第一步:使用Vue CLI的vue命令新建一个名为
01_vuecli_demo
的Vue3版本项目。

输入命令:

vue create 01_vuecli_demo

出现如下Vue CLI脚手架默认提供的三个预设。

Vue CLI v5.0.8
? Please pick a preset: (Use arrow keys)
> Default ([Vue 3] babel, eslint)
  Default ([Vue 2] babel, eslint)
  Manually select features
  • (1) Default ([Vue 3] babel, eslint):新建vue3默认项目,项目集成babel,eslint插件
  • (2) Default ([Vue 2] babel, eslint):新建vue2默认项目,项目集成babel,eslint插件
  • (3) Manually select features:新建项目,手动选择项目所需的功能,如是否需要babel和eslint插件

第二步:手动选择所需的功能。

根据需要选择相应的功能。

提示:“选中”和“取消选中”是按空格键,“上下移动”是按上下键,“确认”是按Enter键。

>(*) Babel
 ( ) TypeScript
 ( ) Progressive Web App (PWA) Support
 ( ) Router
 ( ) Vuex
 ( ) CSS Pre-processors
 ( ) Linter / Formatter
 ( ) Unit Testing
 ( ) E2E Testing

说明:

  • babel:是否使用Babel作为JavaScript编译器,结合插件将ES6/7/8/9/10等语法转换为ES5语法。
  • TypeScript:是否使用TypeScript。
  • Progressive Web App (PWA) Support:是否支持PWA。PWA是渐进式web应用-一种无需要安装的网页应用,具有与原生应用相同的用户体验优势。
  • Router:是否默认集成路由。
  • Vuex:是否默认集成Vuex状态管理。Vuex用于在多个组件间共享数据。
  • CSS Pre-processors:是否选用CSS预处理器,即常用的Less、Scss、Stylus预处理器。
  • Linter / Formatter:是否选择Eslint对代码进行格式化限制。
  • Unit Testing:是否添加单元测试。
  • E2E Testing:是否添加E2E测试。

第三步:选择Vue.js版本。

根据需要选择vue版本,这儿示例选择vue3.x版本。

 3.x
 2.x

第四步:选择配置存放的位置。

 In dedicated config files
 In package.json

这儿选择“In dedicated config files”,意思就是将babel、eslint等配置信息统一放到各自独立的配置文件中,而不是放到
package.json
文件中。

第五步:是否保存为自定义预设。

Save this as a preset for future projects? (y/N)

输入y,表示保存自定义预设,也可以输入n,即不保存自定义预设。

如果保存了预设,在下次新建项目时,在第一步选择预设时,就可以看到我们保存过的预设,比如我们把前面的预设命名为“vue3-demo”,最后按"Enter"键即可。

第六步:新建成功的提示。

 $ cd 01_vuecli_demo
 $ npm run serve

vue.js 3 项目目录结构

01_vuecli_demo/  项目名称
|-- node_modules         #存放第三方依赖包(例如,执行npm i安装的依赖包)
|-- public/              #静态资源目录  
|   |-- favicon.ico      #网站图标  
|   |-- index.html       #项目的入口文件  
|-- src/                 #项目的源代码目录  
|   |-- assets/          #静态资源目录,如图片、字体等  
|   |-- components/      #可复用的 Vue 组件  
|   |-- router/          #Vue Router 的路由配置  
|   |   |-- index.js     #路由的主文件  
|   |-- store/           #Vuex 的状态管理  
|   |   |-- index.js     #状态管理的主文件  
|   |-- views/           #页面目录  
|   |   |-- About.vue    #关于页面  
|   |   |-- Home.vue     #首页  
|   |-- App.vue          #根组件  
|   |-- main.js          #项目的入口文件  
|-- .browserslistrc      #Browserslist 配置,用于 Autoprefixer 和其他工具确定目标浏览器和 Node.js 版本范围  
|-- .eslintignore        #ESLint 忽略的文件  
|-- .eslintrc.js         #ESLint 配置  
|-- .gitignore           #Git 忽略的文件  
|-- babel.config.js      #Babel 插件的配置文件 
|-- package-lock.json    #npm 依赖的锁定文件  
|-- package.json         #项目的元数据文件和 npm 脚本  
|-- README.md            #项目的说明文件 
|-- vue.config.js        #Vue CLI 配置文件,比如配置alias、devServer和configure Webpack等

项目的运行和打包

"serve": "vue-cli-service serve",
"build": "vue-cli-service build"

vue.config.js文件解析

  • 1.outputDir:用于指定打包输出的文件名,默认是dist目录。如果想修改目录名称,可以使用outputDir配置。
module.exports = {
  outputDir: 'build'
}

对于使用 Vue CLI 5.x创建的项目,vue.config.js同样支持使用defineConfig宏函数,以获得更好的代码智能提示,示例代码如下:

// defineConfig 宏函数只支持 Vue CLI 5.x 
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true, // 如果选择true,那么项目引用node_modules中的包也会用Babel来编译,否则不会编译
  outputDir: 'build'
})
  • 2.assetsDir:用于指定静态资源存放目录。该属性是相对于outputDir路径。
module.exports = {
  outputDir: 'build',
  assetsDir: 'static'
}

编译后,index.html资源引用情况如下:

<script defer="defer" src="/static/js/chunk-vendors.abc53625.js"></script>
<script defer="defer" src="/static/js/app.0af7aca5.js"></script>
<link href="/static/css/app.bf008658.css" rel="stylesheet">
  • 3.publicPath:用于指定引用资源的前缀。
// defineConfig 宏函数只支持 Vue CLI 5.x 
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true, // 如果选择true,那么项目引用node_modules中的包也会用Babel来编译,否则不会编译
  outputDir: 'build',
  assetsDir: 'static',
  publicPath: './'
})

当进行上述相对路径配置后,在index.html代码如下:

<script defer="defer" src="static/js/chunk-vendors.abc53625.js"></script>
<script defer="defer" src="static/js/app.0af7aca5.js"></script>
<link href="static/css/app.bf008658.css" rel="stylesheet">
  • 4.alias:用于配置导包路径的别名。
    例如,当项目的目录结构比较深的时候,配置一个路径别名提高了代码的可读性和维护性。
const path = require('path');
function resolve (dir) {
  return path.join(__dirname, dir);
}


// defineConfig 宏函数只支持 Vue CLI 5.x 
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true, // 如果选择true,那么项目引用node_modules中的包也会用Babel来编译,否则不会编译
  outputDir: 'build',
  assetsDir: 'static',
  publicPath: './',
  chainWebpack: (config) => {
    config.resolve.alias
      .set('@', resolve('src'))
      .set('assets', resolve('src/assets'))
      .set('components', resolve('src/components'))
  }
})


vuejs 3
项目中,可以在
vue.config.js
文件中的
chainWebpack
属性上配置
alias

chainWebpack
是一个函数,该函数会接收一个基于
webpack-chain

config
实例,允许对
webpack
配置进行更细粒度的修改。

上述配置完成后,例如HelloWorld组件的引入方式可以调整为如下两种方式:

import HelloWorld from 'components/HelloWorld.vue'
import HelloWorld from '@/components/HelloWorld.vue'
  • 5.devServer: 开发环境的服务配置

所有
webpack-dev-server
的选项都支持。注意:

  • 有些值像 host、port 和 https 可能会被命令行参数覆写。
  • 有些值像 publicPath 和 historyApiFallback 不应该被修改,因为它们需要和开发服务器的 publicPath 同步以保障正常的工作。

示例:

const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({
  transpileDependencies: true,
  devServer: {
    host: "localhost",
    port: 8083,
    open: true,
    proxy: {},
  },
});

开心一刻

记得小时候,家里丢了钱,是我拿的,可爸妈却一口咬定是弟弟拿的

爸爸把弟弟打的遍体鳞伤,弟弟气愤的斜视着我

我不敢直视弟弟,目光转向爸爸说到:爸爸,你看他,好像还不服

还不服

问题描述

项目基于
POI 4.1.2
生成
Excel 2007
文件,已经对接了很多客户,也稳定运行了好几年了;就在前两天,对接一个新的客户,生成的 Excel 2007 文件导入他们的系统失败,提示:

-700006004当前Excel表单列名中未查找到该列.

实话实说,这个提示对我而言,一毛钱作用没有,那就只能问他们系统的开发人员了;经过半天的排查,他们的开发人员给出的结论是:

你们的Excel 2007文件看着像是旧版的,不符合新版标准

这个回答让我更懵了,触及到我的知识盲区,都不直到如何接话了

又是知识盲区

Excel 2007 文件还有标准与非标准之分?这个问题我们先不纠结,本着优先解决问题的原则,试着去尝试升级下 POI 的版本

为什么第一时间想到的是升级 POI 版本?因为是用 POI 生成的 Excel 2007 文件嘛(貌似等于没说)

将 POI 版本升级到
5.3.0
,代码不做任何调整,重新生成文件发送给客户,客户验证可以正常导入;你们是不是以为事情到此告一段落,升级 POI 版本就好了嘛,我只能说你们是有了新欢忘了旧爱,已经对接的客户怎么办?你敢保证升级 POI 后生成的 Excel 2007(2003 也会跟着受影响)还能正常导入这些客户的系统吗,所以我们的野心能不能更大一些:新欢旧爱都要!

新欢旧爱我都要

既对已有客户不造成影响,又能满足新客户要求,也就引申出了本文标题

不升级 POI 版本,如何生成符合新版标准的Excel 2007文件

是个压缩包


Excel 2007
开始,Microsoft 采用了新的文件格式,称为开放的
XML
文件格式,很好地改进了文件和数据管理、数据恢复和可交互能力;而 Excel 2007 就是是一个包含 XML、图片等文件的压缩包;我们暂且先只关注 XML,先基于
POI 4.1.2

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi</artifactId>
    <version>4.1.2</version>
</dependency>
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>4.1.2</version>
</dependency>
String filePath = "D:/POI_4_1_2.xlsx";

public void createExcel(String filePath) throws Exception {
    try(SXSSFWorkbook wb = new SXSSFWorkbook();
        OutputStream os = Files.newOutputStream(Paths.get(filePath))) {
        SXSSFSheet sheetA = wb.createSheet("a");
        SXSSFSheet sheetB = wb.createSheet("b");
        SXSSFRow sheetA_row1 = sheetA.createRow(0);
        sheetA_row1.createCell(0).setCellValue("hello world");
        sheetA_row1.createCell(1).setCellValue("666");
        SXSSFRow sheetA_row2 = sheetA.createRow(1);
        sheetA_row2.createCell(0).setCellValue("888");
        sheetA_row2.createCell(1).setCellValue("999");
        SXSSFRow sheetB_row1 = sheetB.createRow(0);
        sheetB_row1.createCell(0).setCellValue("qsl");
        sheetB_row1.createCell(1).setCellValue("青石路");
        wb.write(os);
        os.flush();
    }
}

生成个旧版的 Excel 2007 文件:
POI_4_1_2.xlsx
,直接用
7z
进行提取(也可以直接将 POI_4_1_2.xlsx 重命名成 POI_4_1_2.zip,然后进行解压)

7z解压

解压之后目录结构如下

都是xml文件

所有的文件都是
XML
;将 POI 升级到
5.3.0

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi</artifactId>
    <version>5.3.0</version>
</dependency>
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.3.0</version>
</dependency>
String filePath = "D:/POI_5_3_0.xlsx";

public void createExcel(String filePath) throws Exception {
    try(SXSSFWorkbook wb = new SXSSFWorkbook();
        OutputStream os = Files.newOutputStream(Paths.get(filePath))) {
        SXSSFSheet sheetA = wb.createSheet("a");
        SXSSFSheet sheetB = wb.createSheet("b");
        SXSSFRow sheetA_row1 = sheetA.createRow(0);
        sheetA_row1.createCell(0).setCellValue("hello world");
        sheetA_row1.createCell(1).setCellValue("666");
        SXSSFRow sheetA_row2 = sheetA.createRow(1);
        sheetA_row2.createCell(0).setCellValue("888");
        sheetA_row2.createCell(1).setCellValue("999");
        SXSSFRow sheetB_row1 = sheetB.createRow(0);
        sheetB_row1.createCell(0).setCellValue("qsl");
        sheetB_row1.createCell(1).setCellValue("青石路");
        wb.write(os);
        os.flush();
    }
}

解压
POI_5_3_0.xlsx
,目录结构与 POI_4_1_2.xlsx 的解压目录结构一致,文件名与文件数量也一致

poi5_3_0目录结构

关于

Excel 2007 文件是个压缩包!

相信大家没疑问了吧;我们来对比下两个目录

新旧目录结构对比

虽然差异文件挺多,但可以归为两类

  1. standalone 差异

    _rels\.rels
    docProps\core.xml
    xl\_rels\workbook.xml.rels
    [Content_Types].xml
    

    这四个文件的差异是一样的(四个文件都是一行,我为了突显差异,将相同的换到了第二行)


    standalone差异

    POI 4.1.2 生成的 xml 中的 standalone 值是
    no
    ,而 POI 5.3.0 生成的 xml 中的 standalone 值是
    yes
    ,就这么一个区别


    core.xml 中还有一个差异:

    core时间差异

    创建时间不同是正常的,这个差异可以忽略

  2. dimension 差异

    xl\worksheets
    目录下存放的是 sheet 相关的 xml,但是名字是 sheet1 ~ sheetn,而不是我们代码中指定的
    a

    b
    ,有多少个 sheet,对应就会有多少个 xml 文件,我们只需要看其中某个 xml 文件的差异即可,其他类似


    sheet_xml差异

    就一处差异:POI 4.1.2 生成的 sheet 中是
    <dimension ref="A1"/>
    ,而 POI 5.3.0 中是
    <dimension ref="A1:B2"/>

这么看来,Excel 2007 文件确实有标准与非标之分

回到问题

不升级 POI 版本,如何生成符合新版标准的Excel 2007文件

你们会如何处理?

要保证不影响已对接的客户(潜台词就是:既不能更换掉 POI,也不能升级 POI)的同时,还要能生成标准版的 Excel 2007文件来满足新客户,感觉没什么办法了呀,只能增加配置项:
是否生成标准Excel 2007
,默认值是:

,表示生成非标Excel 2007文件,保证已对接的客户不受影响,配置项值如果是:

,则生成标准Excel 2007文件;那么问题又来了

标准Excel 2007文件如何生成?

通过 POI 生成肯定是不行了,因为不能升级其版本,生成的是非标Excel 2007文件,那怎么办呢,我们可以换个组件嘛,条条大路通罗马,生成Excel 2007的组件肯定不只有 POI,换个组件来生成标准Excel 2007文件就好了嘛

其他组件

阿里的
EasyExcel
,你们肯定都知道吧,那就用它来生成标准Excel 2007文件,引入依赖

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>4.0.2</version>
</dependency>

我们来看下它的依赖树

easyexcel_依赖

框住的部分,你们应该能看懂吧;EasyExcel 依赖 POI,但因为 POI 4.1.2 的优先级高于 EasyExcel 依赖的 5.2.5,所以最终依赖的还是 POI 4.1.2

关于 maven 的优先级可查看:
结合实例看 maven 传递依赖与优先级,难顶也得上丫

此时你们是不是懵逼了?

EasyExcel怎么依赖POI

显然用 EasyExcel 行不通;我还试了
jxl
,发现也不行(解压后目录结构完全不一样),没有去试其他组件,因为我想到了一种感觉可行的方案

重打包

还记得前面的目录对比吗,差异文件分两类,standalone 差异固定是 4 个文件

_rels\.rels
docProps\core.xml
xl\_rels\workbook.xml.rels
[Content_Types].xml

dimension 差异固定为一类文件

xl\worksheets\sheet*.xml

除了这些差异文件,其他文件都是一致的,那么我们是不是可以这样处理

Excel 2007 文件还是基于 POI 4.1.2 生成,若配置项:
是否生成标准Excel 2007
未配置或者配置的是

,则文件生成结束(既有逻辑),如果配置项配置的是:

,则对生成好的 Excel 2007 进行以下处理

  1. 解压生成好的 Excel 2007 文件
  2. 对差异文件进行修改,将对应的差异项修改成标准值
  3. 重新打包成 Excel 2007 文件,并替换掉之前的旧 Excel 2007 文件

这样是不是就实现需求了?方案有了那就试呗

  1. 解压

    就用 POI 依赖的
    commons-compress
    进行解压即可

    /**
     * 对 Excel 2007 文件进行解压
     * @param sourceFile 源Excel 2007文件
     * @param unzipDir 解压目录
     * @throws IOException 解压异常
     * @author 青石路
     */
    private void unzip(File sourceFile, String unzipDir) throws IOException {
        try (ZipFile zipFile = new ZipFile(sourceFile)) {
            // 遍历 ZIP 文件中的每个条目
            Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
            while(entries.hasMoreElements()) {
                ZipArchiveEntry entry = entries.nextElement();
                // 创建输出文件的路径
                Path outputPath = Paths.get(unzipDir, entry.getName());
                if (!Files.exists(outputPath.getParent())) {
                    // 确保父目录存在
                    Files.createDirectories(outputPath.getParent());
                }
                try (InputStream inputStream = zipFile.getInputStream(entry);
                     FileOutputStream outputStream = new FileOutputStream(outputPath.toFile())) {
                    IOUtils.copy(inputStream, outputStream);
                }
            }
        }
    }
    
  2. 修改

    standalone 值修改

    /**
     * 修改xml 的 standalone 属性值
     * @param filePath 包含 standalone 属性的xml文件
     * @throws IOException IO异常
     * @author 青石路
     */
    private void updateXmlStandalone(Path filePath) throws IOException {
        Path bakPath = Paths.get(filePath.getParent().toString(), filePath.getFileName() + "_bak");
        try (BufferedReader reader = Files.newBufferedReader(filePath)) {
            String line = reader.readLine();
            String replace = line.replace("standalone=\"no\"", "standalone=\"yes\"");
            Files.write(bakPath, replace.getBytes(StandardCharsets.UTF_8));
        }
        Files.delete(filePath);
        Files.move(bakPath, filePath);
    }
    

    dimension 修改,首先我们需要弄清楚
    ref
    值的含义


    // POI 4.1.2

    // POI 5.3.0


    POI 4.1.2 中,ref 的值仅表示起始坐标,A表示X坐标值,1表示Y坐标值,而在 POI 5.3.0 中,ref 的值不仅有起始坐标,还包括结束坐标,
    A1
    表示起始坐标,
    B2
    表示结束坐标,这里的 2 表示数据行数

    /**
     * 修改xml 的 dimension ref 属性值
     * @param sheetDir sheet xml所在目录
     * @throws IOException IO异常
     * @author 青石路
     */
    private void updateSheetXmlDimension(Path sheetDir) throws IOException {
        // 修改第二行中的 <dimension ref="A1"/>
        try (Stream<Path> filePaths = Files.list(sheetDir)) {
            filePaths.forEach(filePath -> {
                // 先获取列数和行数,rows:数据行数,totalRows:内容总行数
                AtomicInteger columns = new AtomicInteger(0);
                AtomicInteger rows = new AtomicInteger(0);
                try (Stream<String> lines = Files.lines(filePath)) {
                    lines.forEach(line -> {
                        if (line.endsWith("</row>")) {
                            rows.incrementAndGet();
                        }
                        if (rows.get() == 1 && line.endsWith("</row>")) {
                            columns.set(line.split("</c>").length - 1);
                        }
                    });
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
                // Excel 列坐标 A ~ Z,AA ~ ZZ,...
                int circleTimes = columns.get() % 26 == 0 ? (columns.get() / 26 - 1) : (columns.get() / 26);
                StringBuilder sb = new StringBuilder();
                for (int i = 0; i < circleTimes; i++) {
                    sb.append("A");
                }
                sb.append((char) ('A' + (columns.get() % 26 == 0 ? 25 : (columns.get() % 26 - 1))));
                // <dimension ref="A1:B2"/>
                String objStr = "<dimension ref=\"A1:" + sb + rows.get();
                try {
                    Path bakPath = Paths.get(filePath.getParent().toString(), filePath.getFileName() + "_bak");
                    Files.createFile(bakPath);
                    try (Stream<String> lines = Files.lines(filePath)) {
                        lines.forEach(line -> {
                            try {
                                if (line.contains("<dimension ref=\"A1")) {
                                    line = line.replace("<dimension ref=\"A1", objStr);
                                }
                                if (!line.endsWith("</worksheet>")) {
                                    line = line + "\n";
                                }
                                Files.write(bakPath, line.getBytes(StandardCharsets.UTF_8), StandardOpenOption.APPEND);
                            } catch (IOException e) {
                                throw new RuntimeException(e);
                            }
                        });
                    }
                    Files.delete(filePath);
                    Files.move(bakPath, filePath);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });
        };
    }
    

    这个代码稍微复杂一点,但可以归纳为以下几步


    1. 遍历 sheet xml文件的内容,得到列数和行数

    2. 根据列数去推算出最大列坐标(B),再根据行数(2)得到结束坐标(B2),那么 ref 的值也就是:
      A1:B2


      这里有个小坑,当数据只有一行一列时,新版的 ref 的值与旧版的 ref 值一致,都是
      A1
      ,但上述代码得到却是
      A1:A1
      ,所以还需要兼容调整下,至于如何调整,就交给你们了,我这里只是提示你们要注意这个坑!!!

    3. 进行 sheet xml 数据拷贝,并用
      <dimension ref=\"A1:B2
      替换掉
      <dimension ref=\"A1
      ,最后用新的 sheet xml 文件替换旧的

  3. 打包

    需要修改的 xml 文件都修改完成之后重新进行打包,这里继续用
    commons-compress

    /**
     * 重新打包成 xlsx
     * @param basePath 解压根目录([Content_Types].xml所在目录)
     * @param oriFile 源Excel 2007文件
     * @throws IOException
     * @author 青石路
     */
    private void repackage(String basePath, File oriFile) throws IOException {
        File newFile = new File(basePath + ".xlsx");
        try (FileOutputStream fos = new FileOutputStream(newFile);
             ZipArchiveOutputStream zaos = new ZipArchiveOutputStream(fos)) {
            // 获取源文件夹下的所有文件和子文件夹
            File srcDir = new File(basePath);
            for (File f : Objects.requireNonNull(srcDir.listFiles())) {
                addToZip(f, "", zaos);
            }
        }
        // 用新文件覆盖原文件
        Path oriPath = oriFile.toPath();
        Files.delete(oriPath);
        Files.move(newFile.toPath(), oriPath);
    }
    
    private void addToZip(File file, String parentFolder, ZipArchiveOutputStream zaos) throws IOException {
        if (file.isDirectory()) {
            // 如果是目录,则遍历其中的文件并递归调用 addToZip
            for (File childFile : Objects.requireNonNull(file.listFiles())) {
                addToZip(childFile, parentFolder + file.getName() + "/", zaos);
            }
        } else {
            // 如果是文件,则将其添加到 ZIP 文件中
            try (FileInputStream fis = new FileInputStream(file)) {
                // 创建一个不带第一层目录的 ZipArchiveEntry
                String entryName = parentFolder + file.getName();
                if (entryName.startsWith("/")) {
                    entryName = entryName.substring(1);
                }
                ZipArchiveEntry entry = new ZipArchiveEntry(entryName);
                zaos.putArchiveEntry(entry);
                IOUtils.copy(fis, zaos);
                zaos.closeArchiveEntry();
            }
        }
    }
    

    没什么复杂点,相信你们都能看懂

  4. 串联

    将上面 3 步串起来

    /**
     * 重打包Excel2007文件
     * @param ifExcel2007New 是否重新打包
     * @param xlsxFile xlsx源文件
     * @throws IOException
     * @author 青石路
     */
    private void repackageExcel2007(boolean ifExcel2007New, File xlsxFile) throws IOException {
        if (!ifExcel2007New) {
            return;
        }
        Path unzipDir = Files.createTempDirectory("");
        try {
            String basePath = Paths.get(unzipDir.toString(), xlsxFile.getName().substring(0, xlsxFile.getName().lastIndexOf("."))).toString();
            // 解压xlsx
            unzip(xlsxFile, basePath);
            // 修改xml
            updateXmlStandalone(Paths.get(basePath, "_rels", ".rels"));
            updateXmlStandalone(Paths.get(basePath, "docProps", "core.xml"));
            updateXmlStandalone(Paths.get(basePath, "xl", "_rels", "workbook.xml.rels"));
            updateXmlStandalone(Paths.get(basePath, "[Content_Types].xml"));
            updateSheetXmlDimension(Paths.get(basePath, "xl", "worksheets"));
            // 打包成xlsx
            repackage(basePath, xlsxFile);
        } finally {
            // 删除临时文件夹
            try (Stream<Path> walk = Files.walk(unzipDir)) {
                walk.sorted(Comparator.reverseOrder())
                        .map(Path::toFile)
                        .forEach(File::delete);
            }
        }
    }
    

    至此,大功告成!我已经试过了,重打包之后的 Excel 2007 文件,用 Windows 的 Excel 工具能正常打开,WPS 也能正常打开,给新客户测试,也能正常导入,简直完美!


    愣着干啥,鼓掌

    总结


    1. Excel 2007 文件是集 xml、图片等文件的压缩包

    2. 引入新功能时,一定不能影响已有功能


      都说了能不动就别动,非要去调整,出生产事故了吧

    3. 可以通过解压、修改、打包的方式,修改Excel 2007文件的元数据

    4. 解压与打包都用
      commons-compress
      ,用别的可能会有惊吓!

前置芝士

$\LARGE {关于二叉搜索树及平衡树无聊的一大串子定义}$

二叉搜索树(BST树)

定义

二叉搜索树是一种二叉树的树形数据结构,其定义如下:

  • 空树是二叉搜索树。

  • 若二叉搜索树的左子树不为空,则其左子树上所有点的附加权值均小于其根节点的值。

  • 若二叉搜索树的右子树不为空,则其右子树上所有点的附加权值均大于其根节点的值。

  • 二叉搜索树的左右子树均为二叉搜索树。

复杂度

二叉搜索树上的基本操作所花费的时间与这棵树的高度成
\(\color{#40c0bb}{正比}\)
。对于一个有
\(n\)
个结点的二叉搜索树中,这些操作的最优时间复杂度为
\(O(\log n)\)
,最坏为
\(O(n)\)
。随机构造这样一棵二叉搜索树的
\(\color{#40c0bb}{期望高度}\)

\(O(\log n)\)

性质

其实也就是定义


\(x\)
是二叉搜索树中的一个结点。

如果
\(y\)

\(x\)
左子树中的一个结点,那么
\(y.key≤x.key\)

如果
\(y\)

\(x\)
右子树中的一个结点,那么
\(y.key≥x.key\)

在二叉搜索树中:

  1. 若任意结点的左子树不空,则左子树上所有结点的值均不大于它的根结点的值。

  2. 若任意结点的右子树不空,则右子树上所有结点的值均不小于它的根结点的值。

  3. 任意结点的左、右子树也分别为二叉搜索树。

用途

二叉搜索树通常可以高效地完成以下操作:

  1. 查找最小/最大值

  2. 搜索元素

  3. 插入一个元素

  4. 删除一个元素

  5. 求元素的排名

  6. 查找排名为 k 的元素

平衡树

定义

由二叉搜索树的复杂度分析可知:操作的复杂度与树的高度
\(h\)
有关。

那么我们可以通过一定操作维持树的高度(平衡性)来降低操作的复杂度,这就是
\(\color{#40c0bb}{平衡树}\)

\(\color{#40c0bb} \large \textbf{平衡性}\)
通常指每个结点的左右子树的高度之差的绝对值(平衡因子)最多为
\(1\)

平衡的调整过程——树旋转

定义

树旋转是在二叉树中的一种子树调整操作, 每一次旋转并
\(\color{#40c0bb}{不影响}\)
对该二叉树进行
\(\color{#40c0bb}{中序遍历}\)
的结果。
树旋转通常应用于需要调整树的局部平衡性的场合。树旋转包括两个不同的方式,分别是
\(\color{#40c0bb}{左旋(Left Rotate 或者  zag)}\)

\(\color{#40c0bb}{右旋(Right Rotate 或者 zig)}\)
。 两种旋转呈镜像,而且互为逆操作。

具体操作

右旋

对于结点
\(A\)
的右旋操作是指:将
\(A\)
的左孩子
\(B\)
向右上旋转,代替
\(A\)
成为根节点,将
\(A\)
结点向右下旋转成为
\(B\)
的右子树的根结点,
\(B\)
的原来的右子树变为
\(A\)
的左子树。

左旋

完全同理

具体情况

其实你只需要知道二叉搜索树的几条基本性质即可:

  1. 每个结点都满足左子树的结点的值都
    小于
    自己的值,右子树的结点的值都
    大于
    自己的值,
    左右子树也是二叉搜索树

  2. 中序遍历二叉搜索树可以得到一个由这棵树的所有结点的值组成的
    有序
    序列。(即所有的值排序后的结果)

正片

背景

不难发现
\(BST树\)
的一种极端情况:
\(\color{#40c0bb}{退化情况}\)

这种
毒瘤
数据让时间复杂度从
\(O(log(n))\)
退化到了恐怖的
\(O(n)\)

于是就有各种各样的科学家们,开始思考人生,丧心病狂地创造出了各种优化BST的方法...

Splay

定义

啥是
\(Splay\)

她实际上就是一种可以旋转的平衡树。
她可以通过
Splay/伸展操作
不断将某个节点旋转到根节点,使得整棵树仍然满足二叉查找树的性质,能够在均摊
\(O(\log n)\)
时间内完成插入,查找和删除操作,并且保持平衡而不至于退化成链。

原理&实现

节点维护信息

rt tot fa[N] ch[N][2] val[N] cnt[N] sz[N]
节点编号 父节点 子节点 左0右1 权值 节点大小 子树大小

基本操作

首先你要了解的就是一些基本操作:

  • \(maintain(x)\)
    :在改变节点位置后,将节点
    \(x\)

    \(\text{size}\)
    更新。
  • \(get(x)\)
    :判断节点
    \(x\)
    是父亲节点的左儿子还是右儿子。
  • \(clear(x)\)
    :清空节点
    \(x\)

void maintain(int x){
	sz[x]=sz[ch[x][0]]+sz[ch[x][1]]+cnt[x];
}
	
bool get(int x){
	return x==ch[fa[x]][1];
}
	
void clear(int x){
	ch[x][0]=ch[x][1]=fa[x]=val[x]=sz[x]=cnt[x]=0;
}

很简单对吧?

接下来可就要上难度了。

旋转操作(rotate)

由定义可知,我们要将某个节点旋转到根节点。

而要想知道怎么将一个节点旋转到根节点,首先要考虑怎么将她旋转到父亲节点。

当该节点为左儿子时

如图,方框表示子树,圆框表示节点

现在,我们要将
\(x\)
节点往上爬一层到他的父节点
\(y\)
,为了保证不改变中序遍历顺序,我们可以让
\(y\)
成为
\(x\)
的右儿子。

但是原来的
\(x\)
节点是有右儿子
\(B\)
的,显然我们要把
\(B\)
换一个位置才能达到目的。

我们知道:
\(x\)
节点的右子树必然是大于
\(x\)
节点的;
\(y\)
节点必然是大于
\(x\)
节点的右子树和
\(x\)
节点本身的(因为
\(x\)
节点及其右子树都是原来
\(y\)
的左子树,肯定比
\(y\)
小(根据二叉搜索树性质))

因此我们可以把
\(x\)
节点原来的右子树放在
\(y\)
的左儿子的位置上,达成目的。

实际上,这也就是
\(\color{#40c0bb}\textbf{右旋}\)
的原理。

当该节点为右儿子时

原理相同。

旋转为

通解

若节点
\(x\)

\(y\)
节点的位置
\(z\)
(
\(z=0\)
为左节点,
\(z=1\)
为右节点 )


  1. \(y\)
    节点放到
    \(x\)
    节点的
    \(z \oplus 1\)
    的位置.(也就是,
    \(x\)
    节点为
    \(y\)
    节点的右子树,那么
    \(y\)
    节点就放到左子树,
    \(x\)
    节点为
    \(y\)
    节点左子树,那么
    \(y\)
    节点就放到右子树位置)

  2. 如果说
    \(x\)
    节点的
    \(z \oplus 1\)
    位置上,已经有节点,或者一棵子树,那么我们就将原来
    \(x\)
    节点
    \(z \oplus 1\)
    位置上的子树,放到
    \(y\)
    节点的位置
    \(z\)
    上面.

这里有个小口诀:“
左旋拎右左挂右,右旋拎左右挂左

看懂文字了,就可以尝试理解一下代码了。

实现

void rotate(int x){
	int y=fa[x],z=fa[y],chk=get(x);
    //y为x的父亲,z为x的爷爷,chk判断x是左儿子还是右儿子

	ch[y][chk]=ch[x][chk^1];
	if(ch[x][chk^1]) fa[ch[x][chk^1]]=y;
	ch[x][chk^1]=y;
	fa[y]=x;
	fa[x]=z;
	if(z) ch[z][y==ch[z][1]]=x;
	maintain(y),maintain(x);
}

Splay操作

Splay(x,to)
是要将
\(x\)
节点旋转至
\(to\)
节点。

单旋

很暴力的办法,对于
\(x\)
节点,每次上旋至
\(fa[x]\)
,直到
\(to\)
节点。

但是,如果你真的这么写可能会
T成SB
被某些毒瘤数据卡成

\(n^2\)

所以不要看单旋简单好写,这里更推荐双旋的写法。

双旋

双旋的优化在于:

  1. 如果当前处于共线状态的话,那么先旋转
    \(y\)
    ,再旋转
    \(x\)
    。这样可以强行让他们不处于共线状态,然后平衡这棵树.

  2. 如果当前不是共线状态的话,那么只要旋转
    \(x\)
    即可。

void splay(int x,int goal=0){
	if(goal==0) rt=x;
	while(fa[x]!=goal){
		int f=fa[x];
		if(fa[fa[x]]!=goal){
			rotate(get(x)==get(f)?f:x);
		}
		rotate(x);
	}
}

查找操作

当然你也可以不写。

查找操作是因为查
\(k\)
的前驱后继时需要将
\(k\)
旋到根节点的位置。

实际上你也可以直接
splay(k,0)
或先插入
\(k\)
查询后再将它删去。

Splay也是一颗二叉搜索树,因此满足左侧都比他小,右侧都比他大。

因此只需要相应的往左/右递归即可。

void find(int x){
    int u=root;
    if(!u)return;//树空
    while(t[u].ch[x>t[u].val]&&x!=t[u].val)
        u=t[u].ch[x>t[u].val];
    splay(u,0);
}

插入操作

  • 如果树空了,则直接插入根并退出。
  • 如果当前节点的权值等于
    \(k\)
    则增加当前节点的大小并更新节点和父亲的信息,将当前节点进行 Splay 操作。
  • 否则按照二叉查找树的性质(左侧都比他小,右侧都比他大)向下找,找到空节点就插入即可。
void ins(int k){//insert
	if(!rt){
		val[++tot]=k;
		cnt[tot]++;
		rt=tot;
		maintain(rt);
		return;
	}
	int cur=rt,f=0;
	while(1){
		if(val[cur]==k){
			cnt[cur]++;
			maintain(cur);
			maintain(f);
			splay(cur);
			break;
		}
		f=cur;
		cur=ch[cur][val[cur]<k];
		if(!cur){
			val[++tot]=k;
			cnt[tot]++;
			fa[tot]=f;
			ch[f][val[f]<k]=tot;
			maintain(tot);
			maintain(f);
			splay(tot);
			break;
		}
	}
}

查询
\(x\)
的排名

  • 如果
    \(x\)
    比当前节点的权值小,向其左子树查找。
  • 如果
    \(x\)
    比当前节点的权值大,将答案加上左子树(
    \(size\)
    )和当前节点(
    \(cnt\)
    )的大小,向其右子树查找。
  • 如果
    \(x\)
    与当前节点的权值相同(已存在),将答案加
    \(1\)
    并返回。
int rk(int k){//the rank of "k"
	int res=0,cur=rt;
	while(1){
		if(k<val[cur]){
			cur=ch[cur][0];
		}else{
			res+=sz[ch[cur][0]];
			if(!cur) return res+1;
			if(k==val[cur]){
				splay(cur);
				return res+1;
			}
			res+=cnt[cur];
			cur=ch[cur][1];
		}
	}
}

查询排名
\(x\)
的数

  • 如果左子树非空且剩余排名
    \(k\)
    不大于左子树的大小
    \(size\)
    ,那么向左子树查找。
  • 否则将
    \(k\)
    减去左子树的和根的大小。如果此时
    \(k\)
    的值小于等于
    \(0\)
    ,则返回根节点的权值,否则继续向右子树查找。
int kth(int k){//the number whose rank is "k"
	int cur=rt;
	while(1){
		if(ch[cur][0] && k<=sz[ch[cur][0]]){
			cur=ch[cur][0];
		}else{
			k-=cnt[cur]+sz[ch[cur][0]];
			if(k<=0){
				splay(cur);
				return val[cur];
			}
			cur=ch[cur][1];
		}
	}
}

查询前驱&后继

前驱就是
\(x\)
的左子树中最右边的节点

后继就是
\(x\)
的右子树中最左边的节点

前驱

int pre(){//precursor
	int cur=ch[rt][0];
	if(!cur) return cur;
	while(ch[cur][1]) cur=ch[cur][1];
	splay(cur);
	return cur;
}

后继

其实就是查前驱的反面

int nxt(){//next or successor
	int cur=ch[rt][1];
	if(!cur) return cur;
	while(ch[cur][0]) cur=ch[cur][0];
	splay(cur);
	return cur;
}

查前驱后继有好多种写法,如果想偷懒只写一遍就可以酱紫

int prenxt(int x,int k){//0 pre 1 nxt
	find(x);
	int cur=rt;
	if(!k && val[cur]<x) return cur;
	if(k && val[cur]>x) return cur;
	cur=ch[cur][k];
	while(ch[cur][!k]){
		cur=ch[cur][!k];
	}
	return cur;
}

删除操作

首先将
\(x\)
旋转到根的位置。

  • 如果
    \(cnt[x]>1\)
    (有不止一个
    \(x\)
    ),那么将
    \(cnt[x] - 1\)
    并退出。

  • 否则,合并它的左右两棵子树即可。

void del(int k){//delete
	rk(k);
	if(cnt[rt]>1){
		cnt[rt]--;
		maintain(rt);
		return;
	}
	if(!ch[rt][0] && !ch[rt][1]){//树空
		clear(rt);
		rt=0;
		return;
	}
	if(!ch[rt][0]){
		int cur=rt;
		rt=ch[rt][1];
		fa[rt]=0;
		clear(cur);
		return;
	}
	if(!ch[rt][1]){
		int cur=rt;
		rt=ch[rt][0];
		fa[rt]=0;
		clear(cur);
		return;
	}
	int cur=rt,x=pre();
	fa[ch[cur][1]]=x;
	ch[x][1]=ch[cur][1];
	clear(cur);
	maintain(rt);
}

那么恭喜你,你已经学完了Splay的基本操作。

至于区间翻转什么的...

下次丕定

Code

Elaina's Code
struct Slpay{
	int rt;//根 
	int tot;//节点编号 
	int fa[N];//父节点 
	int ch[N][2];//子节点 左0右1 
	int val[N];//权值 
	int cnt[N];//节点大小 
	int sz[N];//子树大小 
	
	void maintain(int x){
		sz[x]=sz[ch[x][0]]+sz[ch[x][1]]+cnt[x];
	}
	
	bool get(int x){
		return x==ch[fa[x]][1];
	}
	
	void clear(int x){
		ch[x][0]=ch[x][1]=fa[x]=val[x]=sz[x]=cnt[x]=0;
	}
	
	
	void rotate(int x){
		int y=fa[x],z=fa[y],chk=get(x);
		ch[y][chk]=ch[x][chk^1];
		if(ch[x][chk^1]) fa[ch[x][chk^1]]=y;
		ch[x][chk^1]=y;
		fa[y]=x;
		fa[x]=z;
		if(z) ch[z][y==ch[z][1]]=x;
		maintain(y);
		maintain(x);
	}
	
	void splay(int x,int goal=0){
		if(goal==0) rt=x;
		while(fa[x]!=goal){
			int f=fa[x];
			if(fa[fa[x]]!=goal){
				rotate(get(x)==get(f)?f:x);
			}
			rotate(x);
		}
	}
	
	void ins(int k){//insert
		if(!rt){
			val[++tot]=k;
			cnt[tot]++;
			rt=tot;
			maintain(rt);
			return;
		}
		int cur=rt,f=0;
		while(1){
			if(val[cur]==k){
				cnt[cur]++;
				maintain(cur);
				maintain(f);
				splay(cur);
				break;
			}
			f=cur;
			cur=ch[cur][val[cur]<k];
			if(!cur){
				val[++tot]=k;
				cnt[tot]++;
				fa[tot]=f;
				ch[f][val[f]<k]=tot;
				maintain(tot);
				maintain(f);
				splay(tot);
				break;
			}
		}
	}
	
	int rk(int k){//the rank of "k"
		int res=0,cur=rt;
		while(1){
			if(k<val[cur]){
				cur=ch[cur][0];
			}else{
				res+=sz[ch[cur][0]];
				if(!cur) return res+1;
				if(k==val[cur]){
					splay(cur);
					return res+1;
				}
				res+=cnt[cur];
				cur=ch[cur][1];
			}
		}
	}
	
	int kth(int k){//the number whose rank is "k"
		int cur=rt;
		while(1){
			if(ch[cur][0] && k<=sz[ch[cur][0]]){
				cur=ch[cur][0];
			}else{
				k-=cnt[cur]+sz[ch[cur][0]];
				if(k<=0){
					splay(cur);
					return val[cur];
				}
				cur=ch[cur][1];
			}
		}
	}
	
	int pre(){//precursor
		int cur=ch[rt][0];
		if(!cur) return cur;
		while(ch[cur][1]) cur=ch[cur][1];
		splay(cur);
		return cur;
	}
	
	int nxt(){//next or successor
		int cur=ch[rt][1];
		if(!cur) return cur;
		while(ch[cur][0]) cur=ch[cur][0];
		splay(cur);
		return cur;
	}
	
	void del(int k){//delete
		rk(k);
		if(cnt[rt]>1){
			cnt[rt]--;
			maintain(rt);
			return;
		}
		if(!ch[rt][0] && !ch[rt][1]){
			clear(rt);
			rt=0;
			return;
		}
		if(!ch[rt][0]){
			int cur=rt;
			rt=ch[rt][1];
			fa[rt]=0;
			clear(cur);
			return;
		}
		if(!ch[rt][1]){
			int cur=rt;
			rt=ch[rt][0];
			fa[rt]=0;
			clear(cur);
			return;
		}
		int cur=rt,x=pre();
		fa[ch[cur][1]]=x;
		ch[x][1]=ch[cur][1];
		clear(cur);
		maintain(rt);
	}


    void find(int x){
		int cur=rt;
		if(!cur) return;
		while(ch[cur][x>val[cur]]&&x!=val[cur]){
			cur=ch[cur][x>val[cur]];
		}
		splay(cur,0);
	}
	
	int get_pre(int x){
		find(x);
		int cur=rt;
		if(val[cur]<x) return cur;
		cur=ch[cur][0];
		while(ch[cur][1]){
			cur=ch[cur][1];
		}
		return cur;
	}
	
	int get_nxt(int x){
		find(x);
		int cur=rt;
		if(val[cur]>x) return cur;
		cur=ch[cur][1];
		while(ch[cur][0]){
			cur=ch[cur][0];
		}
		return cur;
	}

    int prenxt(int x,int k){//0 pre 1 nxt
		find(x);
		int cur=rt;
		if(!k && val[cur]<x) return cur;
		if(k && val[cur]>x) return cur;
		cur=ch[cur][k];
		while(ch[cur][!k]){
			cur=ch[cur][!k];
		}
		return cur;
	}
}tr;

signed main(){
	int m=rd;
	while(m--){
		int opt=rd,x=rd;
		if(opt==1){
			tr.ins(x);
		}else if(opt==2){
			tr.del(x);
		}else if(opt==3){
			printf("%lld\n",tr.rk(x));
		}else if(opt==4){
			printf("%lld\n",tr.kth(x));
		}else if(opt==5){
			tr.ins(x),printf("%lld\n",tr.val[tr.pre()]),tr.del(x);
		}else{
			tr.ins(x),printf("%lld\n",tr.val[tr.nxt()]),tr.del(x);
		}
	}
	return Elaina;
}

学了这么久 奖励你张图吧

才不是我自己想看