2024年9月

本篇包含 tarjan 求强连通分量、边双连通分量、割点 部分,
tarjan 求点双连通分量、桥(割边)在下一篇。

伟大的 Robert Tarjan 创造了众多被人们所熟知的算法及数据结构,最著名的如:(本文的)连通性相关的 tarjan 算法,Splay-Tree,Toptree,tarjan 求 lca 等等。

注:有向图的强连通分量、无向图的双连通分量、tarjan 求最近公共祖先 被称为 tarjan 三大算法。

所以在本篇博客开篇,%%% Tarjan Dalao.

基础概念:

强连通分量

对于一个
有向图
G,存在一个子图使得从该子图中每个点出发都可以到达该子图中任意一点,则称此子图为 G 的强连通分量。特别的,一个点也是一个强连通分量。

如下图红框部分都是强连通分量:(显然,红框中的点 1、2、3、5 可以互相到达)

image

割点

对于一个
无向连通图
G,若去除 G 中的一个节点 u,并删去与该节点相连的所有边后,G 变得不再连通,则称该点 u 为割点。

如下图标红的 4 号点就是割点:

割边(桥):

在一个连通分量中,如果删除某一条边,会把这个连通分量分成两个连通分量,那么这个边称为割边(桥)。

如此图中的红边

image

点/边双连通分量:

若一个
无向图
中的去掉任意一个节点(一条边)都不会改变此图的连通性,即不存在割点(桥),则称作点(边)双连通图。分别简称点双、边双。

此图既是一个点双又是边双(显然它不含割点也不含桥):



前置知识:

建议先自行了解

dfs 生成树

图片摘自
oi-wiki

邻接链表

存边方式,自行学习(



tarjan 求强连通分量(必看):

需要维护两个数组:

\(dfn_i\)
:表示
\(i\)
这个点的 dfs 序(即深度优先搜索遍历时点
\(i\)
被搜索的次序)

\(low_i\)
:表示从以点
\(i\)
为根节点的子树中任意一点通过一条返祖边能到达的节点的最小值

算法思路:

维护一个栈,存目前正在找的强连通分量的节点。

对图的每个节点进行深度优先搜索,同时维护每个点的
\(low、dfn\)
值,每次搜索到一个点将其加入到栈中。每当找到一个强连通元素(如果一个点的
\(low\)
值和
\(dfn\)
值相等,我们称这个点为强连通元素)时,其实便找全了一个强连通分量。

思路分析(难懂疑问):

  • 如何更新
    \(low\)
    值?

对于搜索过程中搜到的的点
\(u\)
和它连向的点
\(v\)
,有以下三种情况:

1 . 点
\(v\)
还未被访问:此时我们继续对
\(v\)
进行搜索。在回溯过程中用
\(low_v\)
更新
\(low_u\)
。 因为
\(v\)
的子树中的点其实也就是
\(u\)
的子树中点,那么
\(v\)
子树中一个点能到达
\(x\)
点,即为
\(u\)
子树中的点到达了
\(x\)
点。


2 . 点
\(v\)
被访问过并且已经在栈中:(即在当前强连通分量中),被访问过说明
\(v\)
在搜索树中是
\(u\)
的祖先节点,那么从
\(u\)
走到
\(v\)
的边便是我们更新
\(low\)
要用的那条返祖边,所以用
\(dfn_v\)
值更新
\(low_u\)



3 . 点
\(v\)
被访问过但不在栈中:说明该点所在强连通分量已经被找到了,且该点不在现在正找的强连通分量里,那么不用进行操作。


  • 为什么找到强联通元素的时候当前的就找全了一个强连通分量呢?

我们知道强联通元素
\(x\)

\(low_x = dfn_x\)
,说明以
\(x\)
为根的子树中所有点都到达不了
\(x\)
之前的(同一强连通分量中的)点,那么
\(x\)
便是该强连通分量的“起点”。


根据栈先进后出的性质,可以知道栈中从
\(x\)
到栈顶的点都是
\(x\)
子树内的点,并且是和
\(x\)
属于同一个强连通分量的。那么找到
\(x\)
这个强连通元素后,栈中从
\(x\)
到栈顶所有点(这些点是当前的强连通分量中的点)取出即可。

伪代码:

tarjan(点 x){
    low[x] = dfn[x] = ++th; // 更新 dfs 序
    把 x 入栈;
    for(枚举 x 相邻的点 y){
        if(y 未被搜索过){
            tarjan(y);
            low[x] = min(low[x], low[y]);
        }
        if(y 被搜索过且已在栈中){
            low[x] = min(low[x], dfn[y]);
        }
    }
    if(x 为强连通元素){
        scc++; //scc 为强连通分量个数
        将栈中元素从 x 到栈顶依次取出;
    }
}

算法演示:

黑边为
树边
,蓝边为
返祖边

在此连通图中,我们以 1 号点为根进行 dfs,所有点的标号即为它们的 dfs 序:

我们从 1 开始 dfs,把 1 加进栈中,找到 1 相邻的点 2,发现 2 还未被搜索过,那么递归 dfs 2;

把 2 加进栈中,找到与 2 相邻的点 3,3 同样未被搜索,再递归搜索 3;

同样把 3 加进栈中,发现 3 相邻的点 1 已经被搜索过且在已在栈中,那么从 3 到 1 这条边就是一条返祖边,用
\(dfn_1\)
更新
\(low_3\)
,回溯;

回溯过程中,分别用
\(low_3\)
更新
\(low_2\)

\(low_2\)
更新
\(low_1\)

回溯到 1 号节点时,有
\(low_1 = dfn_1\)
,所以 1 号节点为强连通元素,那么栈中从 1 到栈顶所有元素即为一个强连通分量。

算法代码:

int th, top, scc; //分别表示 dfs 的时间戳、栈顶、强连通分量个数
int s[N], ins[N]; //s 为手写栈,ins[i] 表示 i 这个点是否在栈中
int low[N], dfn[N], belong[N]; //belong[i] 表示 i 这个点所属的强连通分量的标号

void tarjan(int x){
    low[x] = dfn[x] = ++th;
    s[++top] = x, ins[x] = true;
    for(int i=head[x]; i; i=nxt[i]){ //链式前向星存边
        int y = to[i];
        if(!dfn[y]){ //若 y 还没被搜索过
            tarjan(y); // 搜索 y 
            low[x] = min(low[x], low[y]);
        }
        else if(ins[y]){ // y 在栈中
            low[x] = min(low[x], dfn[y]);
        }
    }
    if(low[x] == dfn[x]){
        ++scc; 
        do{ //将栈中从 x 到栈顶所有元素取出
            belong[s[top]] = scc;
            ins[s[top]] = false;
        }while(s[top--] != y);
    }
}

但是,大多数题目并不是给定一张联通图,所以一张图可能会分成多个强连通分量,所以主函数中应这样写(来保证每个强连通分量都被跑过 tarjan):

    for(int i=1; i<=n; i++)
        if(!dfn[i]) tarjan(i);

例题:

不要着急看下面的内容,建议做一两道例题熟悉算法原理和代码后再继续学习。

The Cow Prom S[USACO06JAN]
(绝对的板子)

板!

信息传递[NOIP2015 提高组]

特殊的最小环问题,因为这个题保证每个点的出度为 1,所以这个题可以用 tarjan 求强连通分量来做,具体可以去看其他题解(
比如这个
)。

受欢迎的牛[USACO03FALL / HAOI2006]

缩点的思想,但很好理解,求出强联通分量,把每个强连通分量看做一个大点,计算每个大点的出度,若有一个出度为 0 的大点,则这个大点包含的所有奶牛都为明星牛;若有两个及以上出度为 0 的大点(则这些大点里的爱慕都无法传播出去)就 G 了,便不存在明星牛。

具体实现看代码吧
#include<bits/stdc++.h>
using namespace std;

const int N = 2e5 + 10;

int n, m, out[N];
int low[N], dfn[N], ins[N], th;
int s[N], belong[N], top, scc, size[N];
int head[N], to[N], nxt[N], tot;

void addedge(int x, int y){
    to[++tot] = y;
    nxt[tot] = head[x];
    head[x] = tot;
}

void tarjan(int x){
    low[x] = dfn[x] = ++th;
    s[++top] = x, ins[x] = true;
    for(int i=head[x]; i; i=nxt[i]){
        int y = to[i];
        if(!dfn[y]){
            tarjan(y);
            low[x] = min(low[x], low[y]);
        }
        else if(ins[y]){
            low[x] = min(low[x], dfn[y]);
        }
    }
    if(low[x] == dfn[x]){
        ++scc;
        do{
            size[scc]++;
            belong[s[top]] = scc;
            ins[s[top]] = false;
        }while(s[top--] != x);
    }
}

int main(){ 
    ios::sync_with_stdio(false), cin.tie(0), cout.tie(0);

    cin>>n>>m;
    for(int i=1; i<=m; i++){
        int x, y; cin>>x>>y;
        addedge(x, y);
    }

    for(int i=1; i<=n; i++){
        if(!dfn[i]) tarjan(i);
    }

    for(int i=1; i<=n; i++){
        for(int j=head[i]; j; j=nxt[j]){
            int y = to[j];
            if(belong[i] != belong[y]) out[belong[i]]++;
        }
    }

    int cnt = 0, ans = 0;
    for(int i=1; i<=scc; i++){
        if(out[i]) continue;
        cnt++;
        if(cnt > 1){cout<<0; return 0;}
        ans = size[i];
    }

    cout<<ans;


    return 0;
}



tarjan 求边双连通分量

有一种实现是先跑出割桥再找边双,本篇不对此方法进行介绍。

其实我们可以发现边双连通分量就是强连通分量搞到无向图中,求边双的思路也和强连通分量一样。(看代码理解即可,非常简单)

和强连通分量的不同之处在代码中标出了。

算法代码:

void tarjan(int x, int p){
    low[x] = dfn[x] = ++th;
    s[++top] = x, ins[x] = true;
    for(int i=head[x]; i; i=nxt[i]){
        int y = to[i];
        if(y == p) continue;//因为双向边所以搜索时加个判父亲节点
        if(!dfn[y]){
            tarjan(y, x); 
            low[x] = min(low[x], low[y]);
        }
        else low[x] = min(low[x], dfn[y]);
        //因为在无向图中,所以若 y 已经被搜索过则一定是 x 的祖先,不用再判 ins
    }
    if(dfn[x] == low[x]){
        ++scc;
        do{
            belong[s[top]] = scc;
            SCC[scc].emplace_back(s[top]); //将边双存起来,可根据题目需要选择写这句话
        }while(s[top--] != x);
    }
}

但注意题目要求,有时题目可能会有重边时,搜索过程中就不能只特判父亲节点,而是应该记一下边的编号判边。因为有重边的话是可以回到父亲节点的。

例题:

【模板】边双联通分量

板子题,但注意题目可能会有重边,所以需要记一下边防止走“回头路”。

看代码就知道了
#include <bits/stdc++.h>
using namespace std;

const int N = 5e6 + 10;

int n, m, top, th, cnt;
int s[N], low[N], dfn[N], id[N];
int tot, head[N], to[N], nxt[N];

vector<vector<int> >ans;

inline void addedge(int x, int y){
    to[++tot] = y;
    nxt[tot] = head[x];
    head[x] = tot;
    id[tot] = cnt; //id 来记一下边的编号
}

inline void tarjan(int x, int p){
    low[x] = dfn[x] = ++th;
    s[++top] = x;
    for(int i=head[x]; i; i=nxt[i]){
        int y(to[i]), edge = id[i];
        if(edge == p) continue; //解决重边问题
        if(!dfn[y]){
            tarjan(y, edge);
            low[x] = min(low[x], low[y]);
        }
        else low[x] = min(low[x], dfn[y]);
    }
    if(low[x] == dfn[x]){
        vector<int>scc;
        do scc.emplace_back(s[top]);
        while(s[top--] != x);
        ans.emplace_back(scc);
    }
}

int main(){
    ios::sync_with_stdio(false), cin.tie(0), cout.tie(0);
    
    cin>>n>>m;
    for(int i=1; i<=m; i++){
        int x, y; cin>>x>>y;
        if(x == y) continue; ++cnt;
        addedge(x, y), addedge(y, x);
    }

    for(int i=1; i<=n; i++)
        if(!dfn[i]) tarjan(i, 0);

    cout<<ans.size()<<"\n";
    for(auto x : ans){
        cout<<x.size()<<" ";
        for(auto i : x){
            cout<<i<<" ";
        }
        cout<<"\n";
    }


    return 0;
}



动态卷积学习
n
个静态卷积核的线性混合,加权使用它们输入相关的注意力,表现出比普通卷积更优越的性能。然而,它将卷积参数的数量增加了
n
倍,因此并不是参数高效的。这导致不能探索
n>100
的设置(比典型设置
n<10
大一个数量级),推动动态卷积性能边界提升的同时享受参数的高效性。为此,论文提出了
KernelWarehouse
,通过利用卷积参数在同一层内部以及邻近层之间的依赖关系重新定义了“卷积核”、“组装卷积核”和“注意力函数”的基本概念。

来源:晓飞的算法工程笔记 公众号,转载请注明出处

论文: KernelWarehouse: Rethinking the Design of Dynamic Convolution

Introduction


卷积是卷积神经网络(
ConvNets
)中的关键操作。在卷积层中,普通卷积
\(\mathbf{y} = \mathbf{W}*\mathbf{x}\)
通过由一组卷积滤波器定义的相同卷积核
\(\mathbf{W}\)
应用于每个输入样本
\(\mathbf{x}\)
来计算输出
\(\mathbf{y}\)
。为简洁起见,将“卷积核”简称为“核”并省略偏置项。尽管常规卷积的有效性已经通过在许多计算机视觉任务上通过各种
ConvNet
架构进行广泛验证,但最近在高效
ConvNet
架构设计方面的进展表明,被称为
CondConv

DY-Conv
的动态卷积取得了巨大的性能提升。

动态卷积的基本思想是用
\(n\)
个相同维度的卷积核的线性混合来取代常规卷积中的单个核,
\(\mathbf{W}=\alpha_{1}\mathbf{W}_1+...+\alpha_{n}\mathbf{W}_n\)
,其中
\(\alpha_{1},...,\alpha_{n}\)
是由一个依赖于输入的注意力模块生成的标量注意力。受益于
\(\mathbf{W}_1,...,\mathbf{W}_n\)
的加法性质和紧凑的注意力模块设计,动态卷积提高了特征学习能力,而与普通卷积相比只增加了少量的乘加成本。然而,它将卷积参数的数量增加了
\(n\)
倍,因为现代
ConvNet
的卷积层占据了绝大部分参数,这导致了模型大小的大幅增加。目前还很少有研究工作来缓解这个问题。
DCD
通过矩阵分解学习基础核和稀疏残差,以逼近动态卷积。这种逼近放弃了基本的混合学习范式,因此当
\(n\)
变大时无法保持动态卷积的表征能力。
ODConv
提出了一个改进的注意力模块,沿不同维度动态加权静态卷积核,而不是单个维度,这样可以在减少卷积核数量的情况下获得具有竞争力的性能。但是,在相同的
\(n\)
设置下,
ODConv
的参数比原始动态卷积多。最近,有研究直接使用了流行的权重修剪策略,通过多个修剪和重新训练阶段来压缩
DY-Conv

简而言之,基于线性混合学习范式的现有动态卷积方法在参数效率方面存在局限。受此限制,卷积核数量通常设置为
\(n=8\)

\(n=4\)
。然而,一个显而易见的事实是,采用动态卷积构建的
ConvNet
的增强容量来源于通过注意机制增加每个卷积层的卷积核数量
\(n\)
。这导致了所需模型大小和容量之间的基本冲突。因此,论文重新思考了动态卷积的设计,旨在协调这种冲突,使其能够在参数效率的同时探索动态卷积性能边界,即能够设置更大的核数
\(n>100\)
(比典型设置
\(n<10\)
大一个数量级)。需要注意的是,对于现有的动态卷积方法,
\(n>100\)
意味着模型大小将大约比使用普通卷积构建的基础模型大
100
倍以上。

为了实现这一目标,论文提出了一种更通用的动态卷积形式,称为
KernelWarehouse
,主要受到现有动态卷积方法的两个观察的启发:(
1
)它们将常规卷积层中的所有参数视为静态核,将卷积核数量从
1
增加到
\(n\)
,并使用其注意模块将
\(n\)
个静态核组装成线性混合核。虽然直观有效,但它们没有考虑卷积层内部静态核之间的参数依赖关系;(
2
)它们为
ConvNet
的各个卷积层分配不同的
\(n\)
个静态核集合,忽略了相邻卷积层之间的参数依赖关系。与现有方法形成鲜明对比的是,
KernelWarehouse
的核心理念是利用
ConvNet
中同一层和相邻层的卷积参数依赖关系,重新构成动态卷积,以实现在参数效率和表示能力之间取得大幅度改进的权衡。

KernelWarehouse
由三个组件组成,分别是核分区、仓库的构建与共享和对比驱动的注意函数,它们之间紧密相互依赖。核分区利用同一卷积层内的参数依赖关系,重新定义了线性混合中的“核”,以较小的局部尺度而不是整体尺度来定义。仓库构建与共享利用相邻卷积层之间的参数依赖关系,重新定义了跨同一阶段卷积层的“组装核”,并生成了一个包含
\(n\)
个局部核(例如
\(n=108\)
)的大型仓库,用于跨层线性混合共享。对比驱动的注意函数用于解决在具有挑战性的
\(n>100\)
设置下,跨层线性混合学习范式下的注意力优化问题,重新定义了“注意力函数”。在给定不同的卷积参数预算下,
KernelWarehouse
提供了很高的灵活性,允许以足够大的
\(n\)
值来很好地平衡参数效率和表示能力。

作为普通卷积的即插即用替代品,
KernelWarehouse
可以轻松应用于各种类型的
ConvNet
架构,通过对
ImageNet

MS-COCO
数据集进行大量实验证实了
KernelWarehouse
的有效性。一方面,轮你问展示了与现有动态卷积方法相比,
KernelWarehouse
实现了更优越的性能(例如,在
ImageNet
数据集上,使用
KernelWarehouse
训练的
ResNet18
|
ResNet50
|
MobileNetV2
|
ConvNeXT-Tiny
模型达到了
76.05%
|
81.05%
|
75.92%
|
82.55%

top-1
准确率,为动态卷积研究创造了新的性能纪录)。另一方面,论文展示了
KernelWarehouse
的三个组件对于模型准确性和参数效率的性能提升至关重要,而且
KernelWarehouse
甚至可以在减小
ConvNet
的模型大小同时提高模型准确性(例如,论文的
ResNet18
模型相对于基准模型减少了
65.10%
的参数,仍实现了
2.29%
的绝对
top-1
准确率增益),并且也适用于
Vision Transformers
(例如,论文的
DeiT-Tiny
模型达到了
76.51%

top-1
准确率,为基准模型带来了
4.38%
的绝对
top-1
准确率增益)。

Method


Motivation and Components of KernelWarehouse

对于一个卷积层,设
\(\mathbf{x} \in \mathbb{R}^{h \times w \times c}\)
为输入,具有
\(c\)
个特征通道,
\(\mathbf{y} \in \mathbb{R}^{h \times w \times f}\)
为输出,具有
\(f\)
个特征通道,其中
\(h \times w\)
表示通道大小。普通卷积
\(\mathbf{y} = \mathbf{W}*\mathbf{x}\)
使用一个静态卷积核
\(\mathbf{W} \in \mathbb{R}^{k \times k \times c \times f}\)
,包含
\(f\)
个具有空间大小
\(k \times k\)
的卷积滤波器。动态卷积通过一个由注意力模块
\(\phi(x)\)
生成的 $ \alpha_{1},...,\alpha_{n}$ 权重的
\(n\)
个相同维度的静态卷积核
\(\mathbf{W}_1,...,\mathbf{W}_n\)
的线性混合取代普通卷积中的
\(\mathbf{W}\)
定义为:

\[\begin{equation}
\label{eq:00}
\begin{aligned}
& \mathbf{W}=\alpha_{1} \mathbf{W}_1+...+\alpha_{n} \mathbf{W}_n.
\end{aligned}
\end{equation}
\]

正如之前讨论的那样,由于参数效率的缺点,通常将核数量
\(n\)
设置为
\(n<10\)
。论文的主要动机是重新制定这种线性混合学习范式,使其能够探索更大的设置,例如
\(n>100\)
(比典型设置
\(n<10\)
大一个数量级),以推动动态卷积性能边界的提升,同时享受参数效率。为此,
KernelWarehouse
具有三个关键组成部分:核分区、仓库的构建与共享和对比驱动的注意力函数。

Kernel Partition

核分区的主要思想是通过利用同一个卷积层内的参数依赖性来减少核维度。具体而言,对于一个普通的卷积层,将静态卷积核
\(\mathbf{W}\)
沿着空间和通道维度依次划分为
\(m\)
个不相交的部分
\(\mathbf{w}_1\)

...

\(\mathbf{w}_m\)
,称为"核单元",其具有相同的维度。为简洁起见,在这里省略了定义核单元维度的过程。核分区可以被定义为:

\[\begin{equation}
\label{eq:01}
\begin{aligned}
& \mathbf{W} = \mathbf{w}_1\cup...\cup \mathbf{w}_m, \ \\ & \mathrm{and}\ \forall\ i,j\in\{1,...,m\}, i \ne j, \ \mathbf{w}_i \cap \mathbf{w}_j = \mathbf{\emptyset}.
\end{aligned}
\end{equation}
\]

在核分区之后,将核单元
\(\mathbf{w}_1\)

...

\(\mathbf{w}_m\)
视为"局部核",并定义一个包含
\(n\)
个核单元
\(\mathbf{E}=\{\mathbf{e}_1,...,\mathbf{e}_n\}\)
的"仓库",其中
\(\mathbf{e}_1\)

...

\(\mathbf{e}_n\)
的维度与
\(\mathbf{w}_1\)

...

\(\mathbf{w}_m\)
相同。然后,每个核单元
\(\mathbf{w}_1\)

...

\(\mathbf{w}_m\)
都可以视为仓库
\(\mathbf{E}=\{\mathbf{e}_1,...,\mathbf{e}_n\}\)
的一个线性混合:

\[\begin{equation}
\label{eq:02}
\mathbf{w}_i =\alpha_{i1} \mathbf{e}_1+...+\alpha_{in} \mathbf{e}_n, \ \mathrm{and}\ i\in\{1,...,m\},
\end{equation}
\]

其中,
\(\alpha_{i1}\)

...

\(\alpha_{in}\)
是由注意力模块
\(\phi(x)\)
生成的依赖于输入的标量注意力。最后,普通卷积层中的静态卷积核
\(\mathbf{W}\)
被其对应的
\(m\)
个线性混合所取代。

由于核分区的存在,核单元
\(\mathbf{w_i}\)
的维度可以远小于静态卷积核
\(\mathbf{W}\)
的维度。例如,当
\(m=16\)
时,核单元
\(\mathbf{w_i}\)
中的卷积参数数量仅为静态卷积核
\(\mathbf{W}\)

1
/
16
。在预定的卷积参数预算
\(b\)
下,相比于现有的将线性混合定义为
\(n\)
(例如
\(n=4\)
)的"整体核"的动态卷积方法,这使得仓库很容易设置更大的
\(n\)
值(例如
\(n=64\)
)。

Warehouse Construction-with-Sharing

仓库的构建与共享的主要思想是通过简单地利用相邻卷积层之间的参数依赖关系,进一步改进基于仓库的线性混合学习公式,图
2
展示了核分区和仓库构建与共享的过程。具体而言,对于
ConvNet
的同阶段的
\(l\)
个卷积层,通过使用相同的核单元维度来构建一个共享仓库
\(\mathbf{E}=\{\mathbf{e}_1,...,\mathbf{e}_n\}\)
进行核分区。这不仅允许共享仓库具有较大的
\(n\)
值(例如
\(n=188\)
),与层特定的仓库(例如
\(n=36\)
)相比,还可以提高表示能力。由于
ConvNet
的模块化设计机制(即可通过简单的值设定来控制阶段整体维度的缩放),可以简单地为所有同阶段的
\(l\)
个静态卷积核使用公共的维度除数(类似公约数的概念),作为统一的核单元维度来进行核分区。从而自然地确定了同阶段每个卷积层的核单元数量
\(m\)
,以及在给定期望的卷积参数预算
\(b\)
时共享仓库的
\(n\)
。。

  • Convolutional Parameter Budget

对于普通的动态卷积,相对于正常卷积来说,卷积参数预算
\(b\)
始终等于核数量。即
\(b==n\)
,且
\(n>=1\)
。当设置一个较大的
\(n\)
值,例如
\(n=188\)
时,现有的动态卷积方法得到的
\(b=188\)
,导致
ConvNet
主干模型大小增加约
188
倍。而对于
KernelWarehouse
,这些缺点得到了解决。设
\(m_{t}\)

ConvNet
同阶段的
\(l\)
个卷积层中核单元的总数(当
\(l=1\)
时,
\(m_{t}=m\)
)。那么,相对于正常卷积,
KernelWarehouse
的卷积参数预算可以定义为
\(b=n/m_{t}\)
。在实现中,使用相同的
\(b\)
值应用于
ConvNet
的所有卷积层,这样
KernelWarehouse
可以通过改变
\(b\)
值来轻松调整
ConvNet
的模型大小。与正常卷积相比:(
1
)当
\(b<1\)
时,
KernelWarehouse
倾向于减小模型大小;(
2
)当
\(b=1\)
时,
KernelWarehouse
倾向于获得相似的模型大小;(
3
)当
\(b>1\)
时,
KernelWarehouse
倾向于增加模型大小。

  • Parameter Efficiency and Representation Power

有趣的是,通过简单地改变
\(m_{t}\)
(由核分区和仓库构建与共享控制),可以得到适当且较大的
\(n\)
值,以满足所需的参数预算
\(b\)
,为
KernelWarehouse
提供表示能力保证。由于这种灵活性,
KernelWarehouse
可以在不同的卷积参数预算下,在参数效率和表示能力之间取得有利的权衡。

Contrasting-driven Attention Function

在上述的表述中,
KernelWarehouse
的优化与现有的动态卷积方法在三个方面有所不同:(
1
)使用线性混合来表示密集的局部核单元,而不是整体的核(
2
)仓库中的核单元数量显著较大(
\(n>100\)
vs.
\(n<10\)
)(
3
)一个仓库不仅被共享用于表示
ConvNet
的特定卷积层的
\(m\)
个核单元,还被共享用于表示其他
\(l-1\)
个相同阶段的卷积层的每个核单元。然而,对于具有这些优化特性的
KernelWarehouse
,论文发现常见的注意力函数失去了其效果。因此,论文提出了对比驱动的注意力函数(
CAF
)来解决
KernelWarehouse
的优化问题。对于静态核
\(\mathbf{W}\)
的第
\(i\)
个核单元,设
\(z_{i1},...,z_{in}\)
为由紧凑型
SE
注意力模块
\(\phi(x)\)
的第二个全连接层生成的特征
logits
,则
CAF
定义为:

\[\begin{equation}
\label{eq:03}
\alpha_{ij} = \tau\beta_{ij} + (1-\tau) \frac{z_{ij}}{\sum^{n}_{p=1}{|z_{ip}|}}, \ \mathrm{and}\ j\in\{1,...,n\},
\end{equation}
\]

其中,
\(\tau\)
是一个从
\(1\)
线性减少到
\(0\)
的温度参数,在训练初期阶段使用;
\(\beta_{ij}\)
是一个二元值(
0

1
)用于初始化注意力;
\(\frac{z_{ij}}{\sum^{n}_{p=1}{|z_{ip}|}}\)
是一个归一化函数。

CAF
依赖于两个巧妙的设计原则:(
1
)第一项确保在训练开始时,共享仓库中的初始有效核单元(
\(\beta_{ij}=1\)
)被均匀地分配到
ConvNet
的所有
\(l\)
个相同阶段的卷积层的不同线性混合中;(
2
)第二项使得注意力既可以是负值也可以是正值,不同于常见的注意力函数总是产生正的注意力。这鼓励优化过程学习在共享同一仓库的
\(l\)
个相同阶段卷积层上的所有线性混合中形成对比度和多样性的注意力分布(如图
3
所示),从而保证提高模型性能。


CAF
初始化阶段,
\(l\)
个相同阶段卷积层中的
\(\beta_{ij}\)
的设置应确保共享仓库能够:(
1
)在
\(b\geq1\)
时,对于每个线性混合至少分配一个指定的核单元(
\(\beta_{ij}=1\)
);(
2
)在
\(b<1\)
时,对于每个线性混合至多分配一个特定的核单元(
\(\beta_{ij}=1\)
)。论文采用一个简单的策略,在同阶段的
\(l\)
个卷积层的每组线性混合(
\(m_{t}\)
个权重)中分配共享仓库中的全部
\(n\)
个核单元之一,且不重复。当
\(n < m_{t}\)
时,一旦
\(n\)
个核单元被使用完,让剩余的线性混合始终使
\(\beta_{ij}=0\)

Visualization Examples of Attentions Initialization Strategy

使用
\(\tau\)

\(\beta_{ij}\)
的注意力初始化策略来构建
KernelWarehouse
模型。在训练的早期阶段,这个策略强制标量注意力是
one-hot
的形式,以建立核单元和线性混合之间的一对一关系。为了更好地理解这个策略,分别提供了
KW
(
\(1\times\)
)、
KW
(
\(2\times\)
)和
KW
(
\(1/2\times\)
)的可视化示例。

  • Attentions Initialization for KW (
    \(1\times\)
    )

在图
4
中展示了
KernelWarehouse
(
\(1\times\)
)的注意力初始化策略的可视化示例。在此示例中,一个仓库
\(\mathbf{E}=\{\mathbf{e}_{1},\dots,\mathbf{e}_{6},\mathbf{e}_{z}\}\)
被共享给
3
个相邻的卷积层,它们的核维度分别为
\(k\times k \times 2c \times f\)

\(k\times 3k \times c \times f\)

\(k\times k \times c \times f\)
。这些核单元的维度都是
\(k\times k \times c \times f\)
。请注意,核单元
\(\mathbf{e}_{z}\)
实际上并不存在,它一直保持为一个零矩阵。它仅用于注意力归一化,而不用于汇总核单元。这个核单元主要用于当
\(b<1\)
时的注意力初始化,不计入核单元数量
\(n\)
。在训练的早期阶段,根据设定的
\(\beta_{ij}\)
,明确强制每个线性混合与一个特定的核单元建立关系。如图
4
所示,将仓库中的
\(\mathbf{e}_{1},\dots,\mathbf{e}_{6}\)
中的一个分配给每个
3
个卷积层中的
6
个线性混合,没有重复。因此,在训练过程的开始阶段,当温度
\(\tau\)

1
时,使用
KW

\(1\times\)
)构建的
ConvNet
大致可以看作是一个标准卷积的
ConvNet

这里,论文将其与另一种替代方案进行了比较。在这种替代策略中,将所有
\(\beta_{ij}\)
设为
1
,强制每个线性混合与所有核单元均等地建立关系。全连接策略展示了与不使用任何注意力初始化策略的
KernelWarehouse
相似的表现,而论文提出的策略在
top-1
增益方面优于它
1.41%

  • Attentions Initialization for KW (
    \(2\times\)
    )

对于
\(b>1\)

KernelWarehouse
,采用与
KW

\(1\times\)
)中使用的相同的注意力初始化策略。图
5a
展示了
KW

\(2\times\)
)的注意力初始化策略的可视化示例。为了建立一对一的关系,将
\(\mathbf{e}_{1}\)
分配给
\(\mathbf{w}_{1}\)
,将
\(\mathbf{e}_{2}\)
分配给
\(\mathbf{w}_{2}\)
。当
\(b>1\)
时,另一种合理的策略是将多个核单元分配给每个线性混合,而且不重复分配,如图
5b
所示。使用基于
KW

\(4\times\)
)的
ResNet18
主干网络来比较这两种策略。根据表
13
中的结果,可以看到一对一策略表现更好。

  • Attentions Initialization for KW (
    \(1/2\times\)
    )

对于
\(b<1\)

KernelWarehouse
,核单元的数量少于线性混合的数量,这意味着不能采用
\(b\geq1\)
中使用的相同策略。因此,只将仓库中的总共
\(n\)
个核单元分别分配给
\(n\)
个线性混合,而且不重复分配。将
\(\mathbf{e}_{z}\)
分配给所有剩余的线性混合。图
6a
展示了
KW
(
\(1/2\times\)
)的可视化示例。当温度
\(\tau\)

1
时,使用
KW
(
\(1/2\times\)
)构建的
ConvNet
可以大致看作是一个具有分组卷积(
groups
=
2
)的
ConvNet
。论文还提供了我们提出的策略和另一种替代策略的比较结果,该替代策略将
\(n\)
个核单元中的一个分配给每两个线性混合,而且不重复分配。如表
13
所示,一对一策略再次取得了更好的结果,表明为
\(b<1\)
引入额外的核
\(\mathbf{e}_{z}\)
可以帮助
ConvNet
学习到更合适的核单元和线性混合之间的关系。当将一个核单元分配给多个线性混合时,
ConvNet
无法很好地平衡它们之间的关系。

Design Details of KernelWarehouse

训练模型的每个相应的
\(m\)

\(n\)
的值在表
14
中提供。请注意,
\(m\)

\(n\)
的值根据设置的核单元维度、共享仓库的层以及
\(b\)
自然确定。

算法
1
展示了给定一个
ConvNet
主干网络和所需的卷积参数预算
\(b\)
时,
KernelWarehouse
的实现。

  • Design details of Attention Module of KernelWarehouse

在现有的动态卷积方法中,
KernelWarehouse
也采用了一种紧凑的
SE
类型结构作为注意力模块
\(\phi(x)\)
(如图
1
所示),用于生成对仓库中的核单元进行加权的注意力。对于任何具有静态核
\(\mathbf{W}\)
的卷积层,按通道的全局平均池化(
GAP
)操作开始,将输入
\(\mathbf{x}\)
映射为特征向量,然后经过一个全连接(
FC
)层,修正线性单元(
ReLU
),另一个
FC
层和一个对比驱动的注意力函数(
CAF
)。第一个
FC
层将特征向量的长度缩减为原来的
1
/
16
,第二个
FC
层并行生成
\(m\)

\(n\)
个特征
logits
,最终由我们的
CAF
逐组进行归一化。

  • Design details of KernelWarehouse on ResNet18


KernelWarehouse
中,一个仓库被分享给所有相同阶段的卷积层。虽然这些层最初根据其输入特征图的分辨率被划分到不同的阶段,但在
KernelWarehouse
中,这些层根据其核维度被划分到不同的阶段。在论文的实现中,通常将每个阶段的第一层(或前两层)重新分配到前一个阶段。


15
展示了基于
KW

\(1\times\)
)的
ResNet18
主干网络的一个示例。通过重新分配层,可以避免由于最大公共维度因子造成的所有其他层都必须根据单个层进行划分的情况。对于
ResNet18
主干网络,将
KernelWarehouse
应用于除第一层以外的所有卷积层。在每个阶段,相应的仓库被共享给其所有的卷积层。对于
KW

\(1\times\)
)、
KW

\(2\times\)
)和
KW

\(4\times\)
),使用静态核的最大公共维度因子作为核分割的统一核单元维度。对于
KW

\(1/2\times\)
)和
KW

\(1/4\times\)
),使用最大公共维度因子的一半。

  • Design details of KernelWarehouse on ResNet50

对于
ResNet50
主干网络,将
KernelWarehouse
应用于除前两层以外的所有卷积层。在每个阶段,相应的仓库被共享给其所有的卷积层。对于
KW

\(1\times\)
)和
KW

\(4\times\)
),使用静态核的最大公共维度因子作为核分割的统一核单元维度。对于
KW

\(1/2\times\)
),使用最大公共维度因子的一半。

  • Design details of KernelWarehouse on ConvNeXt-Tiny

对于
ConvNeXt
主干网络,将
KernelWarehouse
应用于所有的卷积层。将
ConvNeXt-Tiny
主干网络第三阶段的
9

Block
划分为具有相等块数的三个阶段。在每个阶段中,相应的三个仓库分别共享给点卷积层、深度卷积层和下采样层。对于
KW

\(1\times\)
),使用静态核的最大公共维度因子作为核分割的统一核单元维度。对于
KW

\(3/4\times\)
),将
KW

\(1/2\times\)
)应用于
ConvNeXt
主干网络后两个阶段的点卷积层,使用最大公共维度因子的一半。对于其他层,使用最大公共维度因子的
KW

\(1\times\)
)。

  • Design details of KernelWarehouse on MobileNetV2

对于基于
KW

\(1\times\)
)和
KW

\(4\times\)
)的
MobileNetV2

\(1.0 \times\)
)和
MobileNetV2

\(0.5 \times\)
)主干网络,将
KernelWarehouse
应用于所有的卷积层。对于基于
KW

\(1\times\)
)的
MobileNetV2

\(1.0 \times\)

\(0.5 \times\)
),在每个阶段,相应的两个仓库分别共享给点卷积层和深度卷积层。对于基于
KW

\(4\times\)
)的
MobileNetV2

\(1.0 \times\)

\(0.5 \times\)
),在每个阶段,相应的三个仓库分别共享给深度卷积层、通道扩展的点卷积层和通道减少的点卷积层,使用静态核的最大公共维度因子作为核分割的统一核单元维度。对于基于
KW

\(1/2\times\)
)的
MobileNetV2

\(1.0 \times\)
)和
MobileNetV2

\(0.5 \times\)
),考虑到注意力模块和分类器层的参数以减少总参数数量。将
KernelWarehouse
应用于所有深度卷积层、最后两个阶段的点卷积层和分类器层。为点卷积层设置
\(b=1\)
,而对于其他层设置
\(b=1/2\)
。对于深度卷积层,使用静态核的最大公共维度因子作为核分割的统一核单元维度。对于点卷积层,使用最大公共维度因子的一半。对于分类器层,使用维度为
\(1000 \times 32\)
的核单元维度。

Discussion

需要注意的是,采用多分支组卷积的分裂与合并策略已经广泛应用于许多
ConvNet
架构中。虽然
KernelWarehouse
在核分区中也使用了参数分割的思想,但重点和动机与它们明显不同。此外,由于使用普通卷积,
KernelWarehouse
也可以用来提高它们的性能。

根据其公式,当在核分区中统一设置
\(m=1\)
(即每个仓库中的所有核单元都具有与普通卷积中的静态核
\(\mathbf{W}\)
相同的维度)并且在仓库共享中设置
\(l=1\)
(即每个仓库仅用于特定的卷积层)时,
KernelWarehouse
将退化为普通的动态卷积。因此,
KernelWarehouse
是动态卷积的一种更通用形式。

在公式中,
KernelWarehouse
的三个关键组成部分密切相互依赖,它们的联合正则化效应导致了在模型准确性和参数效率方面显著提高的性能,这一点在实验部分通过多个剔除实验证明了。

Experiments


Image Classification on ImageNet Dataset

  • ConvNet Backbones

选择了来自
MobileNetV2

ResNet

ConvNeXt
的五种
ConvNet
骨干网络进行实验,包括轻量级和较大的架构。

  • Experimental Setup

在实验中,与相关方法进行了多种比较,以证明其有效性。首先,在
ResNet18
骨干网络上,与各种最先进的基于注意力的方法进行了比较,包括:(
1

SE

CBAM

ECA
,这些方法专注于特征重新校准;(
2

CGC

WeightNet
,这些方法专注于调整卷积权重;(
3

CondConv

DY-Conv

DCD

ODConv
,这些方法专注于动态卷积。其次,选择
DY-Conv

ODConv
作为关键参考方法,因为它们是最优秀的动态卷积方法,与论文的方法最密切相关。在除了
ConvNeXt-Tiny
之外的所有其他
ConvNet
骨干网络上,将
KernelWarehouse
与它们进行比较(因为在
ConvNeXt
上没有公开可用的实现)。为了进行公平的比较,所有方法都使用相同的训练和测试设置,使用公共代码实现。在实验中,使用
\(b\times\)
来表示相对于正常卷积的每个动态卷积方法的卷积参数预算。

  • Results Comparison with Traditional Training Strategy

  • Results Comparison with Advanced Training Strategy

  • Results Comparison on MobileNets

Detection and Segmentation on MS-COCO Dataset

为了评估通过论文方法训练的分类骨干模型对下游目标检测和实例分割任务的泛化能力,在
MS-COCO
数据集上进行了比较实验。

  • Experimental Setup

采用
Mask R-CNN
作为检测框架,并使用不同的动态卷积方法构建了
ResNet50

MobileNetV2
(
\(1.0\times\)
)作为骨干网络,并在
ImageNet
数据集上进行了预训练。然后,所有模型都在
MS-COCO
数据集上使用标准的
\(1\times\)
调度进行训练。为了进行公平比较,对所有模型采用相同的设置,包括数据处理流程和超参数。

  • Results Comparison

Ablation Studies



如果本文对你有帮助,麻烦点个赞或在看呗~
更多内容请关注 微信公众号【晓飞的算法工程笔记】

work-life balance.

简介

在现代微服务架构中,服务发现(Service Discovery)是一项关键功能。它允许微服务动态地找到彼此,而无需依赖硬编码的地址。以前如果你搜 .NET Service Discovery,大概率会搜到一大堆 Eureka,Consul 等的文章。现在微软为我们带来了一个官方的包:Microsoft.Extensions.ServiceDiscovery。这个包出自 Aspire 项目,提供了一个简便的方式在 .NET 中实现服务发现。

安装 Nuget 包

首先,需要安装 Microsoft 提供的 Service Discovery 包。使用以下命令添加包到你的项目中:

dotnet add package Microsoft.Extensions.ServiceDiscovery

这一步确保你的项目具有使用 Service Discovery 所需的依赖项。

配置和注册服务

接下来,需要在项目中配置和注册 Service Discovery。打开
Program.cs

Startup.cs
文件,并添加以下代码:

builder.Services.AddServiceDiscovery();

builder.Services.ConfigureHttpClientDefaults(static http =>
{
    http.AddServiceDiscovery();
});

这段代码将 Service Discovery 注册到依赖注入容器中,并配置默认的 HTTP 客户端使用 Service Discovery。

配置服务端点

为了让 Service Discovery 知道如何找到其他服务,需要在配置文件(如
appsettings.json
)中定义服务端点。例如:

{
  "Services": {
    "weatherReport": {
      "http": [
        "localhost:5089",
        "127.0.0.1:5089"
      ],
      "https": []
    }
  }
}

在这个配置中,我们定义了名为
weatherReport
的服务的 HTTP 端点。Service Discovery 将使用这些信息来查找和访问该服务。

使用服务名进行 HTTP 调用

配置完成后,可以通过
服务名称
进行 HTTP 调用。以下代码展示了如何使用
IHttpClientFactory
进行服务调用:

app.MapGet("/report", async (IHttpClientFactory factory) =>
{
    const string serviceName = "weatherReport";
    var client = factory.CreateClient();
    var response = await client.GetAsync($"http://{serviceName}/weatherforecast");
    var content = await response.Content.ReadAsStringAsync();

    return content;
});

这段代码创建了一个 HTTP 客户端,通过服务名
weatherReport
发起请求,并返回响应内容。

启动服务后尝试进行调用:

通过观察日志可以看到
http://weatherreport/weatherforecast
被转换成
http://127.0.0.1:5089

http://localhost:5089
的 http 调用。

负载均衡

如果服务配置了多个 endpoint 。 那么进行服务调用的时候我们往往需要按实际情况配置 Load-balancing 的策略:

builder.Services.AddHttpClient<CatalogServiceClient>(
    static client => client.BaseAddress = new("http://weatherReport"));
  .AddServiceDiscovery(RandomServiceEndpointSelector.Instance);
  • PickFirstServiceEndpointSelectorProvider.Instance: 总是调用第一个

  • RoundRobinServiceEndpointSelectorProvider.Instance: 轮询调用

  • RandomServiceEndpointSelectorProvider.Instance: 随机调用

  • PowerOfTwoChoicesServiceEndpointSelectorProvider.Instance: 解释太长看英文原文吧。Power-of-two-choices, which attempts to pick the least heavily loaded endpoint based on the Power of Two Choices algorithm for distributed load balancing, degrading to randomly selecting an endpoint when either of the provided endpoints do not have the IEndpointLoadFeature

总结

Service Discovery 是实现微服务架构的重要组件。在 .NET 中,通过简单的配置和使用,可以不用 hardcode IP 跟 port 而使用服务名,可以大大简化服务间的调用。同时还能配置不同的调用策略,进行负载均衡。

关注我的公众号一起玩转技术

1. ArgoCD 的架构

ArgoCD 是一个 Kubernetes 原生的持续交付工具,它通过监控 Git 仓库中的应用定义来自动部署应用到 Kubernetes 集群。其核心架构由以下几个关键组件构成:

  • API Server
    : ArgoCD 的 API 入口,提供了外部接口以便用户或外部工具与 ArgoCD 进行交互。API Server 同时也是 Web UI 的后台服务。

  • Repository Server
    : 负责与 Git 仓库交互。它从仓库中拉取应用定义,并将这些定义转化为 Kubernetes 清单文件。Repository Server 会缓存从 Git 仓库中获取的文件,以加快后续的操作。

  • Controller
    : 核心控制器,持续监控 Kubernetes 集群的当前状态与期望状态(定义在 Git 仓库中)之间的差异。Controller 负责将集群的状态与 Git 中的期望状态保持一致。

  • Application Controller
    : 负责处理用户定义的 ArgoCD Application 资源。它会检查 Git 仓库中的定义,并确保这些定义与 Kubernetes 集群中的应用状态保持同步。

  • Redis Server
    : 用于缓存数据和提升系统性能,尤其在处理大量应用和频繁同步操作时显得尤为重要。

  • Web UI
    : 提供了一个友好的图形化界面,用户可以通过 Web UI 查看应用状态、同步状态以及进行手动操作。


2. ArgoCD 的工作原理

ArgoCD 的核心理念是 GitOps,即以 Git 仓库作为单一的真理源,通过自动化的方式将仓库中的应用配置同步到 Kubernetes 集群中。

  1. 定义应用
    : 用户在 Git 仓库中定义应用的 Kubernetes 资源清单,并将这些清单文件提交到 Git 仓库。

  2. 创建 ArgoCD Application
    : 在 ArgoCD 中创建一个
    Application
    资源,该资源描述了应用在 Git 仓库中的位置,以及在 Kubernetes 集群中部署的位置。

  3. 同步状态监控
    : ArgoCD Controller 持续监控 Git 仓库中的配置,并与当前集群状态进行对比。每次检测到 Git 仓库中的应用配置发生变化时,Controller 会自动更新集群中的资源,保持与 Git 仓库的一致性。

  4. 自动同步与手动同步
    : ArgoCD 支持自动同步和手动同步。自动同步模式下,一旦检测到 Git 仓库有变化,ArgoCD 会自动更新 Kubernetes 集群中的资源。而在手动同步模式下,用户需要手动触发同步操作。

  5. 回滚功能
    : 如果应用更新导致问题,ArgoCD 提供了回滚功能,用户可以轻松恢复到先前的状态。

3. ArgoCD 自动拉取更新的机制

ArgoCD 通过持续监控 Git 仓库的变更来实现自动拉取和更新机制。其背后工作原理如下:

  1. 定时轮询
    : ArgoCD Controller 会定期轮询指定的 Git 仓库,以检查是否有新的提交。默认情况下,这个轮询周期是每 3 分钟一次。

  2. Webhook 触发
    : 为了更快地响应更新,ArgoCD 支持通过 Git 仓库的 Webhook 来触发同步操作。当仓库中发生提交或合并请求时,GitLab、GitHub 等平台可以通过 Webhook 通知 ArgoCD 立即进行同步。

  3. 状态对比
    : 每次拉取到最新的 Git 仓库状态后,ArgoCD 会与当前集群中的应用状态进行对比。如果发现差异,ArgoCD 会自动执行同步操作,确保集群与 Git 仓库的配置一致。

4. 如何与 GitLab 集成

ArgoCD 可以无缝集成 GitLab,通过 Webhook 和 CI/CD 流水线实现自动化部署。以下是集成步骤:

1. 配置 GitLab 项目

在 GitLab 中创建或选择一个项目,确保项目中包含 Kubernetes 资源清单文件,并将这些文件存储在仓库的一个目录中,例如
manifests/

2. 配置 ArgoCD Application

在 ArgoCD 中创建一个
Application
资源,指向 GitLab 仓库,并设置好相关的路径和目标集群。例如:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: my
-appnamespace: argocd
spec:
project:
defaultsource:
repoURL:
'https://gitlab.com/your-username/your-repo.git'path:'manifests'targetRevision: HEAD
destination:
server:
'https://kubernetes.default.svc' namespace: my-namespacesyncPolicy:
automated:
prune:
trueselfHeal:true

3. 配置 GitLab Webhook

进入 GitLab 项目的
Settings -> Webhooks
,添加一个新的 Webhook,URL 填写 ArgoCD 的 Webhook 地址,通常为
https://argocd.example.com/api/webhook
,选择
Push events

Merge request events
触发器。

4. 验证集成

每当 GitLab 仓库中有新的提交或合并请求时,ArgoCD 会收到 Webhook 通知,并立即触发同步操作。您可以通过 ArgoCD 的 Web UI 查看同步进度和结果。

5. 示例代码

以下是一个完整的 ArgoCD Application 配置示例,与 GitLab 集成并自动同步:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: demo
-appnamespace: argocd
spec:
project:
defaultsource:
repoURL:
'https://gitlab.com/your-username/demo-app.git'path:'k8s-manifests'targetRevision: HEAD
destination:
server:
'https://kubernetes.default.svc' namespace: demo
syncPolicy:
automated:
prune:
trueselfHeal:truewebhook:
gitlab:
- url: https://argocd.example.com/api/webhook secret: your-webhook-secret

在上述配置中,
syncPolicy
中的
automated
参数启用了自动同步和自动修复功能,这意味着 ArgoCD 会自动拉取 GitLab 中的最新配置,并将其应用到 Kubernetes 集群中。


通过上述内容,您应该已经了解了 ArgoCD 的架构、工作原理、自动拉取更新的机制,以及如何将其与 GitLab 集成。ArgoCD 强大的自动化和 GitOps 能力,让您的应用部署更加高效和可靠。

本文记录我读 WPF 源代码的笔记,在 WPF 底层是如何从 Win32 的消息循环获取到的 WM_POINTER 消息处理转换作为 Touch 事件的参数

由于 WPF 触摸部分会兼顾开启 Pointer 消息和不开启 Pointer 消息,为了方便大家理解,本文分为两个部分。第一个部分是脱离 WPF 框架,聊聊一个 Win32 程序如何从 Win32 的消息循环获取到的 WM_POINTER 消息处理转换为输入坐标点,以及在触摸下获取触摸信息。第二部分是 WPF 框架是如何安排上这些处理逻辑,如何和 WPF 框架的进行对接

处理 Pointer 消息

在 Win32 应用程序中,大概有三个方式来进行对 Pointer 消息进行处理。我将从简单到复杂和大家讲述这三个方式

方式1:

接收到 WM_POINTER 消息之后,将 wparam 转换为
pointerId
参数,调用 GetPointerTouchInfo 方法即可获取到
POINTER_INFO
信息

获取
POINTER_INFO

ptPixelLocationRaw
字段,即可拿到基于屏幕坐标系的像素点

只需将其转换为窗口坐标系和处理 DPI 即可使用

此方法的最大缺点在于
ptPixelLocationRaw
字段拿到的是丢失精度的点,像素为单位。如果在精度稍微高的触摸屏下,将会有明显的锯齿效果

优点在于其获取特别简单

方式2:

依然是接收到 WM_POINTER 消息之后,将 wparam 转换为
pointerId
参数,调用 GetPointerTouchInfo 方法即可获取到
POINTER_INFO
信息

只是从获取
POINTER_INFO

ptPixelLocationRaw
字段换成
ptHimetricLocationRaw
字段

使用
ptHimetricLocationRaw
字段的优势在于可以获取不丢失精度的信息,但需要额外调用
GetPointerDeviceRects
函数获取
displayRect

pointerDeviceRect
信息用于转换坐标点

            PInvoke.GetPointerDeviceRects(pointerInfo.sourceDevice, &pointerDeviceRect, &displayRect);

            // 如果想要获取比较高精度的触摸点,可以使用 ptHimetricLocationRaw 字段
            // 由于 ptHimetricLocationRaw 采用的是 pointerDeviceRect 坐标系,需要转换到屏幕坐标系
            // 转换方法就是先将 ptHimetricLocationRaw 的 X 坐标,压缩到 [0-1] 范围内,然后乘以 displayRect 的宽度,再加上 displayRect 的 left 值,即得到了屏幕坐标系的 X 坐标。压缩到 [0-1] 范围内的方法就是除以 pointerDeviceRect 的宽度
            // 为什么需要加上 displayRect.left 的值?考虑多屏的情况,屏幕可能是副屏
            // Y 坐标同理
            var point2D = new Point2D(
                pointerInfo.ptHimetricLocationRaw.X / (double) pointerDeviceRect.Width * displayRect.Width +
                displayRect.left,
                pointerInfo.ptHimetricLocationRaw.Y / (double) pointerDeviceRect.Height * displayRect.Height +
                displayRect.top);

            // 获取到的屏幕坐标系的点,需要转换到 WPF 坐标系
            // 转换过程的两个重点:
            // 1. 底层 ClientToScreen 只支持整数类型,直接转换会丢失精度。即使是 WPF 封装的 PointFromScreen 或 PointToScreen 方法也会丢失精度
            // 2. 需要进行 DPI 换算,必须要求 DPI 感知

            // 先测量窗口与屏幕的偏移量,这里直接取 0 0 点即可,因为这里获取到的是虚拟屏幕坐标系,不需要考虑多屏的情况
            var screenTranslate = new Point(0, 0);
            PInvoke.ClientToScreen(new HWND(hwnd), ref screenTranslate);
            // 获取当前的 DPI 值
            var dpi = VisualTreeHelper.GetDpi(this);
            // 先做平移,再做 DPI 换算
            point2D = new Point2D(point2D.X - screenTranslate.X, point2D.Y - screenTranslate.Y);
            point2D = new Point2D(point2D.X / dpi.DpiScaleX, point2D.Y / dpi.DpiScaleY);

以上方式2的代码放在
github

gitee
上,可以使用如下命令行拉取代码。我整个代码仓库比较庞大,使用以下命令行可以进行部分拉取,拉取速度比较快

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

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 322313ee55d0eeaae7148b24ca279e1df087871e

以上使用的是国内的 gitee 的源,如果 gitee 不能访问,请替换为 github 的源。请在命令行继续输入以下代码,将 gitee 源换成 github 源进行拉取代码。如果依然拉取不到代码,可以发邮件向我要代码

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 322313ee55d0eeaae7148b24ca279e1df087871e

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

方式2的优点在于可以获取到更高的精度。缺点是相对来说比较复杂,需要多了点点处理

方式3:

此方式会更加复杂,但功能能够更加全面,适合用在要求更高控制的应用里面

先调用
GetPointerDeviceProperties
方法,获取 HID 描述符上报的对应设备属性,此时可以获取到的是具备完全的 HID 描述符属性的方法,可以包括
Windows 的 Pen 协议
里面列举的各个属性,如宽度高度旋转角等信息

收到 WM_POINTER 消息时,调用
GetRawPointerDeviceData
获取最原始的触摸信息,再对原始触摸信息进行解析处理

原始触摸信息的解析处理需要先应用获取每个触摸点的数据包长度,再拆数据包。原始触摸信息拿到的是一个二进制数组,这个二进制数组里面可能包含多个触摸点的信息,需要根据数据包长度拆分为多个触摸点信息

解析处理就是除了前面两个分别是属于 X 和 Y 之外,后面的数据就根据
GetPointerDeviceProperties
方法获取到的触摸描述信息进行套入

此方式的复杂程度比较高,且拿到的是原始的触摸信息,需要做比较多的处理。即使解析到 X 和 Y 坐标点之后,还需要执行坐标的转换,将其转换为屏幕坐标系

这里拿到的 X 和 Y 坐标点是设备坐标系,这里的设备坐标系不是
GetPointerDeviceRects
函数获取 的
pointerDeviceRect
设备范围坐标系,而是对应
GetPointerDeviceProperties
方法获取到的描述符的逻辑最大值和最小值的坐标范围

其正确计算方法为从
GetPointerDeviceProperties
方法获取到的 X 和 Y 描述信息,分别取
POINTER_DEVICE_PROPERTY

logicalMax
作为最大值范围。分别将 X 和 Y 除以
logicalMax
缩放到
[0,1]
范围内,再乘以屏幕尺寸即可转换为屏幕坐标系

这里的 屏幕尺寸 是通过
GetPointerDeviceRects
函数获取 的
displayRect
尺寸

转换为屏幕坐标系之后,就需要再次处理 DPI 和转换为窗口坐标系的才能使用

可以看到方式3相对来说还是比较复杂的,但其优点是可以获取到更多的设备描述信息,获取到输入点的更多信息,如可以计算出触摸宽度对应的物理触摸尺寸面积等信息

对于 WPF 框架来说,自然是选最复杂且功能全强的方法了

在 WPF 框架的对接

了解了一个 Win32 应用与 WM_POINTER 消息的对接方式,咱来看看 WPF 具体是如何做的。了解了对接方式之后,阅读 WPF 源代码的方式可以是通过必须调用的方法的引用,找到整个 WPF 的脉络

在开始之前必须说明的是,本文的大部分代码都是有删减的代码,只保留和本文相关的部分。现在 WPF 是完全开源的,基于最友好的 MIT 协议,可以自己拉下来代码进行二次修改发布,想看完全的代码和调试整个过程可以自己从开源地址拉取整个仓库下来,开源地址是:
https://github.com/dotnet/wpf

在 WPF 里面,触摸初始化的故事开始是在
PointerTabletDeviceCollection.cs
里面,调用
GetPointerDevices
方法进行初始化获取设备数量,之后的每个设备都调用
GetPointerDeviceProperties
方法,获取 HID 描述符上报的对应设备属性,有删减的代码如下

namespace System.Windows.Input.StylusPointer
{
    /// <summary>
    /// Maintains a collection of pointer device information for currently installed pointer devices
    /// </summary>
    internal class PointerTabletDeviceCollection : TabletDeviceCollection
    {
        internal void Refresh()
        {
            ... // 忽略其他代码
                    UnsafeNativeMethods.POINTER_DEVICE_INFO[] deviceInfos
                         = new UnsafeNativeMethods.POINTER_DEVICE_INFO[deviceCount];

                    IsValid = UnsafeNativeMethods.GetPointerDevices(ref deviceCount, deviceInfos);
            ... // 忽略其他代码
        }
    }
}

获取到设备之后,将其转换放入到 WPF 定义的 PointerTabletDevice 里面,大概的代码如下

namespace System.Windows.Input.StylusPointer
{
    /// <summary>
    /// Maintains a collection of pointer device information for currently installed pointer devices
    /// </summary>
    internal class PointerTabletDeviceCollection : TabletDeviceCollection
    {
        internal void Refresh()
        {
            ... // 忽略其他代码
                    UnsafeNativeMethods.POINTER_DEVICE_INFO[] deviceInfos
                         = new UnsafeNativeMethods.POINTER_DEVICE_INFO[deviceCount];

                    IsValid = UnsafeNativeMethods.GetPointerDevices(ref deviceCount, deviceInfos);

                    if (IsValid)
                    {
                        foreach (var deviceInfo in deviceInfos)
                        {
                            // Old PenIMC code gets this id via a straight cast from COM pointer address
                            // into an int32.  This does a very similar thing semantically using the pointer
                            // to the tablet from the WM_POINTER stack.  While it may have similar issues
                            // (chopping the upper bits, duplicate ids) we don't use this id internally
                            // and have never received complaints about this in the WISP stack.
                            int id = MS.Win32.NativeMethods.IntPtrToInt32(deviceInfo.device);

                            PointerTabletDeviceInfo ptdi = new PointerTabletDeviceInfo(id, deviceInfo);

                            // Don't add a device that fails initialization.  This means we will try a refresh
                            // next time around if we receive stylus input and the device is not available.
                            // <see cref="HwndPointerInputProvider.UpdateCurrentTabletAndStylus">
                            if (ptdi.TryInitialize())
                            {
                                PointerTabletDevice tablet = new PointerTabletDevice(ptdi);

                                _tabletDeviceMap[tablet.Device] = tablet;
                                TabletDevices.Add(tablet.TabletDevice);
                            }
                        }
                    }
            ... // 忽略其他代码
        }

        /// <summary>
        /// Holds a mapping of TabletDevices from their WM_POINTER device id
        /// </summary>
        private Dictionary<IntPtr, PointerTabletDevice> _tabletDeviceMap = new Dictionary<IntPtr, PointerTabletDevice>();
    }
}

namespace System.Windows.Input
{
    /// <summary>
    ///     Collection of the tablet devices that are available on the machine.
    /// </summary>
    public class TabletDeviceCollection : ICollection, IEnumerable
    {
        internal List<TabletDevice> TabletDevices { get; set; } = new List<TabletDevice>();
    }
}

在 PointerTabletDeviceInfo 的 TryInitialize 方法,即
if (ptdi.TryInitialize())
这行代码里面,将会调用
GetPointerDeviceProperties
获取设备属性信息,其代码逻辑如下

namespace System.Windows.Input.StylusPointer
{
    /// <summary>
    /// WM_POINTER specific information about a TabletDevice
    /// </summary>
    internal class PointerTabletDeviceInfo : TabletDeviceInfo
    {
        internal PointerTabletDeviceInfo(int id, UnsafeNativeMethods.POINTER_DEVICE_INFO deviceInfo)
        {
            _deviceInfo = deviceInfo;

            Id = id;
            Name = _deviceInfo.productString;
            PlugAndPlayId = _deviceInfo.productString;
        }

        internal bool TryInitialize()
        {
            ... // 忽略其他代码

            var success = TryInitializeSupportedStylusPointProperties();

            ... // 忽略其他代码

            return success;
        }

        private bool TryInitializeSupportedStylusPointProperties()
        {
            bool success = false;

            ... // 忽略其他代码

            // Retrieve all properties from the WM_POINTER stack
            success = UnsafeNativeMethods.GetPointerDeviceProperties(Device, ref propCount, null);

            if (success)
            {
                success = UnsafeNativeMethods.GetPointerDeviceProperties(Device, ref propCount, SupportedPointerProperties);

                if (success)
                {
                    ... // 执行更具体的初始化逻辑
                }
            }

            ... // 忽略其他代码
        }

        /// <summary>
        /// The specific id for this TabletDevice
        /// </summary>
        internal IntPtr Device { get { return _deviceInfo.device; } }

        /// <summary>
        /// Store the WM_POINTER device information directly
        /// </summary>
        private UnsafeNativeMethods.POINTER_DEVICE_INFO _deviceInfo;
    }
}

为什么这里会调用
GetPointerDeviceProperties
两次?第一次只是拿数量,第二次才是真正的拿值

回顾以上代码,可以看到 PointerTabletDeviceInfo 对象是在 PointerTabletDeviceCollection 的 Refresh 方法里面创建的,如以下代码所示

    internal class PointerTabletDeviceCollection : TabletDeviceCollection
    {
        internal void Refresh()
        {
            ... // 忽略其他代码
                    UnsafeNativeMethods.POINTER_DEVICE_INFO[] deviceInfos
                         = new UnsafeNativeMethods.POINTER_DEVICE_INFO[deviceCount];

                    IsValid = UnsafeNativeMethods.GetPointerDevices(ref deviceCount, deviceInfos);
                        foreach (var deviceInfo in deviceInfos)
                        {
                            // Old PenIMC code gets this id via a straight cast from COM pointer address
                            // into an int32.  This does a very similar thing semantically using the pointer
                            // to the tablet from the WM_POINTER stack.  While it may have similar issues
                            // (chopping the upper bits, duplicate ids) we don't use this id internally
                            // and have never received complaints about this in the WISP stack.
                            int id = MS.Win32.NativeMethods.IntPtrToInt32(deviceInfo.device);

                            PointerTabletDeviceInfo ptdi = new PointerTabletDeviceInfo(id, deviceInfo);

                            if (ptdi.TryInitialize())
                            {
                                
                            }
                        }
            ... // 忽略其他代码
        }
    }

从 GetPointerDevices 获取到的
POINTER_DEVICE_INFO
信息会存放在
PointerTabletDeviceInfo

_deviceInfo
字段里面,如下面代码所示

    internal class PointerTabletDeviceInfo : TabletDeviceInfo
    {
        internal PointerTabletDeviceInfo(int id, UnsafeNativeMethods.POINTER_DEVICE_INFO deviceInfo)
        {
            _deviceInfo = deviceInfo;

            Id = id;
        }

        /// <summary>
        /// The specific id for this TabletDevice
        /// </summary>
        internal IntPtr Device { get { return _deviceInfo.device; } }

        /// <summary>
        /// Store the WM_POINTER device information directly
        /// </summary>
        private UnsafeNativeMethods.POINTER_DEVICE_INFO _deviceInfo;
    }

调用
GetPointerDeviceProperties
时,就会将
POINTER_DEVICE_INFO

device
字段作为参数传入,从而获取到
POINTER_DEVICE_PROPERTY
结构体列表信息

获取到的
POINTER_DEVICE_PROPERTY
结构体信息和 HID 描述符上报的信息非常对应。结构体的定义代码大概如下

        /// <summary>
        /// A struct representing the information for a particular pointer property.
        /// These correspond to the raw data from WM_POINTER.
        /// </summary>
        [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
        internal struct POINTER_DEVICE_PROPERTY
        {
            internal Int32 logicalMin;
            internal Int32 logicalMax;
            internal Int32 physicalMin;
            internal Int32 physicalMax;
            internal UInt32 unit;
            internal UInt32 unitExponent;
            internal UInt16 usagePageId;
            internal UInt16 usageId;
        }

根据 HID 基础知识可以知道,通过
usagePageId

usageId
即可了解到此设备属性的具体含义。更多请参阅 HID 标准文档:
http://www.usb.org/developers/hidpage/Hut1_12v2.pdf

在 WPF 使用到的 Pointer 的
usagePageId
的只有以下枚举所列举的值

        /// <summary>
        ///
        /// WM_POINTER stack must parse out HID spec usage pages
        /// <see cref="http://www.usb.org/developers/hidpage/Hut1_12v2.pdf"/> 
        /// </summary>
        internal enum HidUsagePage
        {
            Undefined = 0x00,
            Generic = 0x01,
            Simulation = 0x02,
            Vr = 0x03,
            Sport = 0x04,
            Game = 0x05,
            Keyboard = 0x07,
            Led = 0x08,
            Button = 0x09,
            Ordinal = 0x0a,
            Telephony = 0x0b,
            Consumer = 0x0c,
            Digitizer = 0x0d,
            Unicode = 0x10,
            Alphanumeric = 0x14,
            BarcodeScanner = 0x8C,
            WeighingDevice = 0x8D,
            MagneticStripeReader = 0x8E,
            CameraControl = 0x90,
            MicrosoftBluetoothHandsfree = 0xfff3,
        }

在 WPF 使用到的 Pointer 的
usageId
的只有以下枚举所列举的值

       /// <summary>
       ///
       /// 
       /// WISP pre-parsed these, WM_POINTER stack must do it itself
       /// 
       /// See Stylus\biblio.txt - 1
       /// <see cref="http://www.usb.org/developers/hidpage/Hut1_12v2.pdf"/> 
       /// </summary>
       internal enum HidUsage
       {
           TipPressure = 0x30,
           X = 0x30,
           BarrelPressure = 0x31,
           Y = 0x31,
           Z = 0x32,
           XTilt = 0x3D,
           YTilt = 0x3E,
           Azimuth = 0x3F,
           Altitude = 0x40,
           Twist = 0x41,
           TipSwitch = 0x42,
           SecondaryTipSwitch = 0x43,
           BarrelSwitch = 0x44,
           TouchConfidence = 0x47,
           Width = 0x48,
           Height = 0x49,
           TransducerSerialNumber = 0x5B,
       }

在 WPF 的古老版本里面,约定了使用 GUID 去获取 StylusPointDescription 里面的额外数据信息。为了与此行为兼容,在 WPF 里面就定义了 HidUsagePage 和 HidUsage 与 GUID 的对应关系,实现代码如下

namespace System.Windows.Input
{
    /// <summary>
    /// StylusPointPropertyIds
    /// </summary>
    /// <ExternalAPI/>
    internal static class StylusPointPropertyIds
    {
        /// <summary>
        /// The x-coordinate in the tablet coordinate space.
        /// </summary>
        /// <ExternalAPI/>
        public static readonly Guid X = new Guid(0x598A6A8F, 0x52C0, 0x4BA0, 0x93, 0xAF, 0xAF, 0x35, 0x74, 0x11, 0xA5, 0x61);
        /// <summary>
        /// The y-coordinate in the tablet coordinate space.
        /// </summary>
        /// <ExternalAPI/>
        public static readonly Guid Y = new Guid(0xB53F9F75, 0x04E0, 0x4498, 0xA7, 0xEE, 0xC3, 0x0D, 0xBB, 0x5A, 0x90, 0x11);

        public static readonly Guid Z = ...

        ...

        /// <summary>
        ///
        /// WM_POINTER stack usage preparation based on associations maintained from the legacy WISP based stack
        /// </summary>
        private static Dictionary<HidUsagePage, Dictionary<HidUsage, Guid>> _hidToGuidMap = new Dictionary<HidUsagePage, Dictionary<HidUsage, Guid>>()
        {
            { HidUsagePage.Generic,
                new Dictionary<HidUsage, Guid>()
                {
                    { HidUsage.X, X },
                    { HidUsage.Y, Y },
                    { HidUsage.Z, Z },
                }
            },
            { HidUsagePage.Digitizer,
                new Dictionary<HidUsage, Guid>()
                {
                    { HidUsage.Width, Width },
                    { HidUsage.Height, Height },
                    { HidUsage.TouchConfidence, SystemTouch },
                    { HidUsage.TipPressure, NormalPressure },
                    { HidUsage.BarrelPressure, ButtonPressure },
                    { HidUsage.XTilt, XTiltOrientation },
                    { HidUsage.YTilt, YTiltOrientation },
                    { HidUsage.Azimuth, AzimuthOrientation },
                    { HidUsage.Altitude, AltitudeOrientation },
                    { HidUsage.Twist, TwistOrientation },
                    { HidUsage.TipSwitch, TipButton },
                    { HidUsage.SecondaryTipSwitch, SecondaryTipButton },
                    { HidUsage.BarrelSwitch, BarrelButton },
                    { HidUsage.TransducerSerialNumber, SerialNumber },
                }
            },
        };

        /// <summary>
        /// Retrieves the GUID of the stylus property associated with the usage page and usage ids
        /// within the HID specification.
        /// </summary>
        /// <param name="page">The usage page id of the HID specification</param>
        /// <param name="usage">The usage id of the HID specification</param>
        /// <returns>
        /// If known, the GUID associated with the usagePageId and usageId.
        /// If not known, GUID.Empty
        /// </returns>
        internal static Guid GetKnownGuid(HidUsagePage page, HidUsage usage)
        {
            Guid result = Guid.Empty;

            Dictionary<HidUsage, Guid> pageMap = null;

            if (_hidToGuidMap.TryGetValue(page, out pageMap))
            {
                pageMap.TryGetValue(usage, out result);
            }

            return result;
        }
    }
}

通过以上的
_hidToGuidMap
的定义关联关系,调用 GetKnownGuid 方法,即可将
POINTER_DEVICE_PROPERTY
描述信息关联到 WPF 框架层的定义

具体的对应逻辑如下

namespace System.Windows.Input.StylusPointer
{
    /// <summary>
    /// Contains a WM_POINTER specific functions to parse out stylus property info
    /// </summary>
    internal class PointerStylusPointPropertyInfoHelper
    {
        /// <summary>
        /// Creates WPF property infos from WM_POINTER device properties.  This appropriately maps and converts HID spec
        /// properties found in WM_POINTER to their WPF equivalents.  This is based on code from the WISP implementation
        /// that feeds the legacy WISP based stack.
        /// </summary>
        /// <param name="prop">The pointer property to convert</param>
        /// <returns>The equivalent WPF property info</returns>
        internal static StylusPointPropertyInfo CreatePropertyInfo(UnsafeNativeMethods.POINTER_DEVICE_PROPERTY prop)
        {
            StylusPointPropertyInfo result = null;

            // Get the mapped GUID for the HID usages
            Guid propGuid =
                StylusPointPropertyIds.GetKnownGuid(
                    (StylusPointPropertyIds.HidUsagePage)prop.usagePageId,
                    (StylusPointPropertyIds.HidUsage)prop.usageId);

            if (propGuid != Guid.Empty)
            {
                StylusPointProperty stylusProp = new StylusPointProperty(propGuid, StylusPointPropertyIds.IsKnownButton(propGuid));

                // Set Units
                StylusPointPropertyUnit? unit = StylusPointPropertyUnitHelper.FromPointerUnit(prop.unit);

                // If the parsed unit is invalid, set the default
                if (!unit.HasValue)
                {
                    unit = StylusPointPropertyInfoDefaults.GetStylusPointPropertyInfoDefault(stylusProp).Unit;
                }

                // Set to default resolution
                float resolution = StylusPointPropertyInfoDefaults.GetStylusPointPropertyInfoDefault(stylusProp).Resolution;

                short mappedExponent = 0;

                if (_hidExponentMap.TryGetValue((byte)(prop.unitExponent & HidExponentMask), out mappedExponent))
                {
                    float exponent = (float)Math.Pow(10, mappedExponent);

                    // Guard against divide by zero or negative resolution
                    if (prop.physicalMax - prop.physicalMin > 0)
                    {
                        // Calculated resolution is a scaling factor from logical units into the physical space
                        // at the given exponentiation.
                        resolution =
                            (prop.logicalMax - prop.logicalMin) / ((prop.physicalMax - prop.physicalMin) * exponent);
                    }
                }

                result = new StylusPointPropertyInfo(
                      stylusProp,
                      prop.logicalMin,
                      prop.logicalMax,
                      unit.Value,
                      resolution);
            }

            return result;
        }
    }
}

以上的一个小细节点在于对 unit 单位的处理,即
StylusPointPropertyUnit? unit = StylusPointPropertyUnitHelper.FromPointerUnit(prop.unit);
这行代码的实现定义,具体实现如下

    internal static class StylusPointPropertyUnitHelper
    {
        /// <summary>
        /// Convert WM_POINTER units to WPF units
        /// </summary>
        /// <param name="pointerUnit"></param>
        /// <returns></returns>
        internal static StylusPointPropertyUnit? FromPointerUnit(uint pointerUnit)
        {
            StylusPointPropertyUnit unit = StylusPointPropertyUnit.None;

            _pointerUnitMap.TryGetValue(pointerUnit & UNIT_MASK, out unit);

            return (IsDefined(unit)) ? unit : (StylusPointPropertyUnit?)null;
        }

        /// <summary>
        /// Mapping for WM_POINTER based unit, taken from legacy WISP code
        /// </summary>
        private static Dictionary<uint, StylusPointPropertyUnit> _pointerUnitMap = new Dictionary<uint, StylusPointPropertyUnit>()
        {
            { 1, StylusPointPropertyUnit.Centimeters },
            { 2, StylusPointPropertyUnit.Radians },
            { 3, StylusPointPropertyUnit.Inches },
            { 4, StylusPointPropertyUnit.Degrees },
        };

        /// <summary>
        /// Mask to extract units from raw WM_POINTER data
        /// <see cref="http://www.usb.org/developers/hidpage/Hut1_12v2.pdf"/> 
        /// </summary>
        private const uint UNIT_MASK = 0x000F;
    }

这里的单位的作用是什么呢?用于和
POINTER_DEVICE_PROPERTY
的物理值做关联对应关系,比如触摸面积 Width 和 Height 的物理尺寸就是通过大概如下算法计算出来的

                short mappedExponent = 0;

                if (_hidExponentMap.TryGetValue((byte)(prop.unitExponent & HidExponentMask), out mappedExponent))
                {
                    float exponent = (float)Math.Pow(10, mappedExponent);

                    // Guard against divide by zero or negative resolution
                    if (prop.physicalMax - prop.physicalMin > 0)
                    {
                        // Calculated resolution is a scaling factor from logical units into the physical space
                        // at the given exponentiation.
                        resolution =
                            (prop.logicalMax - prop.logicalMin) / ((prop.physicalMax - prop.physicalMin) * exponent);
                    }
                }

        /// <summary>
        /// Contains the mappings from WM_POINTER exponents to our local supported values.
        /// This mapping is taken from WISP code, see Stylus\Biblio.txt - 4,
        /// as an array of HidExponents.
        /// </summary>
        private static Dictionary<byte, short> _hidExponentMap = new Dictionary<byte, short>()
        {
            { 5, 5 },
            { 6, 6 },
            { 7, 7 },
            { 8, -8 },
            { 9, -7 },
            { 0xA, -6 },
            { 0xB, -5 },
            { 0xC, -4 },
            { 0xD, -3 },
            { 0xE, -2 },
            { 0xF, -1 },
        };

通过 resolution 与具体后续收到的触摸点的值进行计算,带上 StylusPointPropertyUnit 单位,这就是触摸设备上报的物理尺寸了

以上
logicalMax

logicalMin
在行业内常被称为逻辑值,以上的
physicalMax

physicalMin
常被称为物理值

经过以上的处理之后,即可将
GetPointerDeviceProperties
拿到的设备属性列表给转换为 WPF 框架对应的定义属性内容

以上过程有一个细节,那就是
GetPointerDeviceProperties
拿到的设备属性列表的顺序是非常关键的,设备属性列表的顺序和在后续 WM_POINTER 消息拿到的裸数据的顺序是直接对应的

大家可以看到,在开启 Pointer 消息时,触摸模块初始化获取触摸信息是完全通过 Win32 的 WM_POINTER 模块提供的相关方法完成的。这里需要和不开 WM_POINTER 消息的从 COM 获取触摸设备信息区分,和
dotnet 读 WPF 源代码笔记 插入触摸设备的初始化获取设备信息
提供的方法是不相同的

完成上述初始化逻辑之后,接下来看看消息循环收到 WM_POINTER 消息的处理

收到 WM_POINTER 消息时,调用
GetRawPointerDeviceData
获取最原始的触摸信息,再对原始触摸信息进行解析处理

在 WPF 里面,大家都知道,底层的消息循环处理的在
HwndSource.cs
里面定义,输入处理部分如下

namespace System.Windows.Interop
{
    /// <summary>
    ///     The HwndSource class presents content within a Win32 HWND.
    /// </summary>
    public class HwndSource : PresentationSource, IDisposable, IWin32Window, IKeyboardInputSink
    {
        private IntPtr InputFilterMessage(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
            ... // 忽略其他代码
            // NOTE (alexz): invoke _stylus.FilterMessage before _mouse.FilterMessage
            // to give _stylus a chance to eat mouse message generated by stylus
            if (!_isDisposed && _stylus != null && !handled)
            {
                result = _stylus.Value.FilterMessage(hwnd, message, wParam, lParam, ref handled);
            }
            ... // 忽略其他代码
        }

        private SecurityCriticalDataClass<IStylusInputProvider>        _stylus;
    }
}

以上代码的
_stylus
就是根据不同的配置参数决定是否使用 Pointer 消息处理的 HwndPointerInputProvider 类型,代码如下

namespace System.Windows.Interop
{
    /// <summary>
    ///     The HwndSource class presents content within a Win32 HWND.
    /// </summary>
    public class HwndSource : PresentationSource, IDisposable, IWin32Window, IKeyboardInputSink
    {
        private void Initialize(HwndSourceParameters parameters)
        {
            ... // 忽略其他代码
            if (StylusLogic.IsStylusAndTouchSupportEnabled)
            {
                // Choose between Wisp and Pointer stacks
                if (StylusLogic.IsPointerStackEnabled)
                {
                    _stylus = new SecurityCriticalDataClass<IStylusInputProvider>(new HwndPointerInputProvider(this));
                }
                else
                {
                    _stylus = new SecurityCriticalDataClass<IStylusInputProvider>(new HwndStylusInputProvider(this));
                }
            }
            ... // 忽略其他代码
        }
    }
}

在本文这里初始化的是 HwndPointerInputProvider 类型,将会进入到 HwndPointerInputProvider 的 FilterMessage 方法处理输入数据

namespace System.Windows.Interop
{
    /// <summary>
    /// Implements an input provider per hwnd for WM_POINTER messages
    /// </summary>
    internal sealed class HwndPointerInputProvider : DispatcherObject, IStylusInputProvider
    {
        /// <summary>
        /// Processes the message loop for the HwndSource, filtering WM_POINTER messages where needed
        /// </summary>
        /// <param name="hwnd">The hwnd the message is for</param>
        /// <param name="msg">The message</param>
        /// <param name="wParam"></param>
        /// <param name="lParam"></param>
        /// <param name="handled">If this has been successfully processed</param>
        /// <returns></returns>
        IntPtr IStylusInputProvider.FilterMessage(IntPtr hwnd, WindowMessage msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
            handled = false;

            // Do not process any messages if the stack was disabled via reflection hack
            if (PointerLogic.IsEnabled)
            {
                switch (msg)
                {
                    case WindowMessage.WM_ENABLE:
                        {
                            IsWindowEnabled = MS.Win32.NativeMethods.IntPtrToInt32(wParam) == 1;
                        }
                        break;
                    case WindowMessage.WM_POINTERENTER:
                        {
                            // Enter can be processed as an InRange.  
                            // The MSDN documentation is not correct for InRange (according to feisu)
                            // As such, using enter is the correct way to generate this.  This is also what DirectInk uses.
                            handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.InRange, Environment.TickCount);
                        }
                        break;
                    case WindowMessage.WM_POINTERUPDATE:
                        {
                            handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.Move, Environment.TickCount);
                        }
                        break;
                    case WindowMessage.WM_POINTERDOWN:
                        {
                            handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.Down, Environment.TickCount);
                        }
                        break;
                    case WindowMessage.WM_POINTERUP:
                        {
                            handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.Up, Environment.TickCount);
                        }
                        break;
                    case WindowMessage.WM_POINTERLEAVE:
                        {
                            // Leave can be processed as an OutOfRange.  
                            // The MSDN documentation is not correct for OutOfRange (according to feisu)
                            // As such, using leave is the correct way to generate this.  This is also what DirectInk uses.
                            handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.OutOfRange, Environment.TickCount);
                        }
                        break;
                }
            }

            return IntPtr.Zero;
        }

        ... // 忽略其他代码
    }
}

对于收到 Pointer 的按下移动抬起消息,都会进入到 ProcessMessage 方法

进入之前调用的
GetPointerId(wParam)
代码的 GetPointerId 方法实现如下

        /// <summary>
        /// Extracts the pointer id
        /// </summary>
        /// <param name="wParam">The parameter containing the id</param>
        /// <returns>The pointer id</returns>
        private uint GetPointerId(IntPtr wParam)
        {
            return (uint)MS.Win32.NativeMethods.SignedLOWORD(wParam);
        }

    internal partial class NativeMethods
    {
        public static int SignedLOWORD(IntPtr intPtr)
        {
            return SignedLOWORD(IntPtrToInt32(intPtr));
        }

        public static int IntPtrToInt32(IntPtr intPtr)
        {
            return unchecked((int)intPtr.ToInt64());
        }

        public static int SignedLOWORD(int n)
        {
            int i = (int)(short)(n & 0xFFFF);

            return i;
        }
    }

当然了,以上代码简单写就和下面代码差不多

            var pointerId = (uint) (ToInt32(wparam) & 0xFFFF);

在 WM_POINTER 的设计上,将会源源不断通过消息循环发送指针消息,发送的指针消息里面不直接包含具体的数据信息,而是只将 PointerId 当成 wparam 发送。咱从消息循环里面拿到的只有 PointerId 的值,转换方法如上述代码所示

为什么是这样设计的呢?考虑到现在大部分触摸屏的精度都不低,至少比许多很便宜鼠标的高,这就可能导致应用程序完全无法顶得住每次触摸数据过来都通过消息循环怼进来。在 WM_POINTER 的设计上,只是将 PointerId 通过消息循环发送过来,具体的消息体数据需要使用
GetPointerInfo
方法来获取。这么设计有什么优势?这么设计是用来解决应用卡顿的时候,被堆积消息的问题。假定现在有三个触摸消息进来,第一个触摸消息进来就发送了 Win32 消息给到应用,然而应用等待到系统收集到了三个触摸点消息时,才调用
GetPointerInfo
方法。那此时系统触摸模块就可以很开森的知道了应用处于卡顿状态,即第二个和第三个触摸消息到来时,判断第一个消息还没被应用消费,就不再发送 Win32 消息给到应用。当应用调用
GetPointerInfo
方法时,就直接返回第三个点给到应用,跳过中间第一个和第二个触摸点。同时,使用历史点的概念,将第一个点和第二个点和第三个点给到应用,如果此时应用感兴趣的话

利用如上所述机制,即可实现到当触摸设备产生的触摸消息过快时,不会让应用的消息循环过度忙碌,而是可以让应用有机会一次性拿到过去一段时间内的多个触摸点信息。如此可以提升整体系统的性能,减少应用程序忙碌于处理过往的触摸消息

举一个虚拟的例子,让大家更好的理解这套机制的思想。假定咱在制作一个应用,应用有一个功能,就是有一个矩形元素,这个元素可以响应触摸拖动,可以用触摸拖动矩形元素。这个应用编写的有些离谱,每次拖动的做法就是设置新的坐标点为当前触摸点,但是这个过程需要 15 毫秒,因为中间添加了一些有趣且保密(其实我还没编出来)的算法。当应用跑在一个触摸设备上,这个触摸设备在触摸拖动的过程中,每 10 毫秒将产生一次触摸点信息报告给到系统。假定当前的系统的触摸模块是如实的每次收到设备发送过来的触摸点,都通过 Win32 消息发送给到应用,那将会让应用的消费速度慢于消息的生产速度,这就意味着大家可以明显看到拖动矩形元素时具备很大的延迟感。如拖着拖着才发现矩形元素还在后面慢慢挪动,整体的体验比较糟糕。那如果采用现在的这套玩法呢?应用程序从 Win32 消息收到的是 PointerId 信息,再通过
GetPointerInfo
方法获取触摸点信息,此时获取到的触摸点就是最后一个触摸点,对于咱这个应用来说刚刚好,直接就是响应设置矩形元素坐标为最后一个触摸点的对应坐标。如此即可看到矩形元素飞快跳着走,且由于刚好矩形元素拖动过程为 15 毫秒,小于 16 毫秒,意味着大部分情况下大家看到的是矩形元素平滑的移动,即飞快跳着走在人类看来是一个连续移动的过程

期望通过以上的例子可以让大家了解到微软的“良苦”用心

这里需要额外说明的是 PointerId 和 TouchDevice 等的 Id 是不一样的,在下文将会给出详细的描述

在 WPF 这边,如上面代码所示,收到触摸点信息之后,将会进入到 ProcessMessage 方法,只是这个过程中我感觉有一点小锅的是,时间戳拿的是当前系统时间戳 Environment.TickCount 的值,而不是取 Pointer 消息里面的时间戳内容

继续看一下 ProcessMessage 方法的定义和实现

namespace System.Windows.Interop
{
    /// <summary>
    /// Implements an input provider per hwnd for WM_POINTER messages
    /// </summary>
    internal sealed class HwndPointerInputProvider : DispatcherObject, IStylusInputProvider
    {
        /// <summary>
        /// Processes the latest WM_POINTER message and forwards it to the WPF input stack.
        /// </summary>
        /// <param name="pointerId">The id of the pointer message</param>
        /// <param name="action">The stylus action being done</param>
        /// <param name="timestamp">The time (in ticks) the message arrived</param>
        /// <returns>True if successfully processed (handled), false otherwise</returns>
        private bool ProcessMessage(uint pointerId, RawStylusActions action, int timestamp)
        {
            ... // 忽略其他代码
        }
    }

    ... // 忽略其他代码
}

在 ProcessMessage 里面将创建 PointerData 对象,这个 PointerData 类型是一个辅助类,在构造函数里面将调用
GetPointerInfo
方法获取指针点信息

        private bool ProcessMessage(uint pointerId, RawStylusActions action, int timestamp)
        {
            bool handled = false;

            // Acquire all pointer data needed
            PointerData data = new PointerData(pointerId);

            ... // 忽略其他代码
        }

以下是 PointerData 构造函数的简单定义的有删减的代码

namespace System.Windows.Input.StylusPointer
{
    /// <summary>
    /// Provides a wrapping class that aggregates Pointer data from a pointer event/message
    /// </summary>
    internal class PointerData
    {
        /// <summary>
        /// Queries all needed data from a particular pointer message and stores
        /// it locally.
        /// </summary>
        /// <param name="pointerId">The id of the pointer message</param>
        internal PointerData(uint pointerId)
        {
            if (IsValid = GetPointerInfo(pointerId, ref _info))
            {
                _history = new POINTER_INFO[_info.historyCount];

                // Fill the pointer history
                // If we fail just return a blank history
                if (!GetPointerInfoHistory(pointerId, ref _info.historyCount, _history))
                {
                    _history = Array.Empty<POINTER_INFO>();
                }

                ... // 忽略其他代码
            }
        }

        /// <summary>
        /// Standard pointer information
        /// </summary>
        private POINTER_INFO _info;

        /// <summary>
        /// The full history available for the current pointer (used for coalesced input)
        /// </summary>
        private POINTER_INFO[] _history;

        /// <summary>
        /// If true, we have correctly queried pointer data, false otherwise.
        /// </summary>
        internal bool IsValid { get; private set; } = false;
    }

通过上述代码可以看到,开始是调用
GetPointerInfo
方法获取指针点信息。在 WPF 的基础事件里面也是支持历史点的,意图和 Pointer 的设计意图差不多,都是为了解决业务端的消费数据速度问题。于是在 WPF 底层也就立刻调用
GetPointerInfoHistory
获取历史点信息

对于 Pointer 消息来说,对触摸和触笔有着不同的数据提供分支,分别是
GetPointerTouchInfo
方法和
GetPointerPenInfo
方法

在 PointerData 构造函数里面,也通过判断
POINTER_INFO

pointerType
字段决定调用不同的方法,代码如下

            if (IsValid = GetPointerInfo(pointerId, ref _info))
            {
                switch (_info.pointerType)
                {
                    case POINTER_INPUT_TYPE.PT_TOUCH:
                        {
                            // If we have a touch device, pull the touch specific information down
                            IsValid &= GetPointerTouchInfo(pointerId, ref _touchInfo);
                        }
                        break;
                    case POINTER_INPUT_TYPE.PT_PEN:
                        {
                            // Otherwise we have a pen device, so pull down pen specific information
                            IsValid &= GetPointerPenInfo(pointerId, ref _penInfo);
                        }
                        break;
                    default:
                        {
                            // Only process touch or pen messages, do not process mouse or touchpad
                            IsValid = false;
                        }
                        break;
                }
            }

对于 WPF 的 HwndPointerInputProvider 模块来说,只处理 PT_TOUCH 和 PT_PEN 消息,即触摸和触笔消息。对于 Mouse 鼠标和 Touchpad 触摸板来说都不走 Pointer 处理,依然是走原来的 Win32 消息。为什么这么设计呢?因为 WPF 里面没有 Pointer 路由事件,在 WPF 里面分开了 Touch 和 Stylus 和 Mouse 事件。就不需要全部都在 Pointer 模块处理了,依然在原来的消息循环里面处理,既减少 Pointer 模块的工作量,也能减少后续从 Pointer 分发到 Touch 和 Stylus 和 Mouse 事件的工作量。原先的模块看起来也跑得很稳,那就一起都不改了

完成 PointerData 的构造函数之后,继续到 HwndPointerInputProvider 的 ProcessMessage 函数里面,在此函数里面判断是 PT_TOUCH 和 PT_PEN 消息,则进行处理

        private bool ProcessMessage(uint pointerId, RawStylusActions action, int timestamp)
        {
            bool handled = false;

            // Acquire all pointer data needed
            PointerData data = new PointerData(pointerId);

            // Only process touch or pen messages, do not process mouse or touchpad
            if (data.IsValid
                && (data.Info.pointerType == UnsafeNativeMethods.POINTER_INPUT_TYPE.PT_TOUCH
                || data.Info.pointerType == UnsafeNativeMethods.POINTER_INPUT_TYPE.PT_PEN))
            {
                ... // 忽略其他代码
            }

            return handled;
        }

对于触摸和触笔的处理上,先是执行触摸设备关联。触摸设备关联一个在上层业务的表现就是让当前的指针消息关联上 TouchDevice 的 Id 或 StylusDevice 的 Id 值

关联的方法是通过
GetPointerCursorId
方法先获取 CursorId 的值,再配合对应的输入的 Pointer 的输入设备
POINTER_INFO

sourceDevice
字段,即可与初始化过程中创建的设备相关联,实现代码如下

            if (data.IsValid
                && (data.Info.pointerType == UnsafeNativeMethods.POINTER_INPUT_TYPE.PT_TOUCH
                || data.Info.pointerType == UnsafeNativeMethods.POINTER_INPUT_TYPE.PT_PEN))
            {
                uint cursorId = 0;

                if (UnsafeNativeMethods.GetPointerCursorId(pointerId, ref cursorId))
                {
                    IntPtr deviceId = data.Info.sourceDevice;

                    // If we cannot acquire the latest tablet and stylus then wait for the
                    // next message.
                    if (!UpdateCurrentTabletAndStylus(deviceId, cursorId))
                    {
                        return false;
                    }

                     ... // 忽略其他代码
                }

                ... // 忽略其他代码
            }

在 WPF 初始化工作里面将输入的 Pointer 的输入设备
POINTER_INFO

sourceDevice
当成
deviceId
的概念,即 TabletDevice 的 Id 值。而
cursorId
则是对应 StylusDevice 的 Id 值,其更新代码的核心非常简单,如下面代码

        /// <summary>
        /// Attempts to update the current stylus and tablet devices for the latest WM_POINTER message.
        /// Will attempt retries if the tablet collection is invalid or does not contain the proper ids.
        /// </summary>
        /// <param name="deviceId">The id of the TabletDevice</param>
        /// <param name="cursorId">The id of the StylusDevice</param>
        /// <returns>True if successfully updated, false otherwise.</returns>
        private bool UpdateCurrentTabletAndStylus(IntPtr deviceId, uint cursorId)
        {
            _currentTabletDevice = tablets?.GetByDeviceId(deviceId);

            _currentStylusDevice = _currentTabletDevice?.GetStylusByCursorId(cursorId);
            
            ... // 忽略其他代码

                if (_currentTabletDevice == null || _currentStylusDevice == null)
                {
                    return false;
                }
            

            return true;
        }

对应的 GetByDeviceId 方法的代码如下

namespace System.Windows.Input.StylusPointer
{
    /// <summary>
    /// Maintains a collection of pointer device information for currently installed pointer devices
    /// </summary>
    internal class PointerTabletDeviceCollection : TabletDeviceCollection
    {
        /// <summary>
        /// Holds a mapping of TabletDevices from their WM_POINTER device id
        /// </summary>
        private Dictionary<IntPtr, PointerTabletDevice> _tabletDeviceMap = new Dictionary<IntPtr, PointerTabletDevice>();

         ... // 忽略其他代码

        /// <summary>
        /// Retrieve the TabletDevice associated with the device id
        /// </summary>
        /// <param name="deviceId">The device id</param>
        /// <returns>The TabletDevice associated with the device id</returns>
        internal PointerTabletDevice GetByDeviceId(IntPtr deviceId)
        {
            PointerTabletDevice tablet = null;

            _tabletDeviceMap.TryGetValue(deviceId, out tablet);

            return tablet;
        }
    }
}

对应的 GetStylusByCursorId 的代码如下

namespace System.Windows.Input.StylusPointer
{  
    /// <summary>
    /// A WM_POINTER based implementation of the TabletDeviceBase class.
    /// </summary>
    internal class PointerTabletDevice : TabletDeviceBase
    {
        /// <summary>
        /// A mapping from StylusDevice id to the actual StylusDevice for quick lookup.
        /// </summary>
        private Dictionary<uint, PointerStylusDevice> _stylusDeviceMap = new Dictionary<uint, PointerStylusDevice>();

        /// <summary>
        /// Retrieves the StylusDevice associated with the cursor id.
        /// </summary>
        /// <param name="cursorId">The id of the StylusDevice to retrieve</param>
        /// <returns>The StylusDevice associated with the id</returns>
        internal PointerStylusDevice GetStylusByCursorId(uint cursorId)
        {
            PointerStylusDevice stylus = null;
            _stylusDeviceMap.TryGetValue(cursorId, out stylus);
            return stylus;
        }
    }
}

调用了 UpdateCurrentTabletAndStylus 的一个副作用就是同步更新了
_currentTabletDevice

_currentStylusDevice
字段的值,后续逻辑即可直接使用这两个字段而不是传参数

完成关联逻辑之后,即进入 GenerateRawStylusData 方法,这个方法是 WPF 获取 Pointer 具体的消息的核心方法,方法签名如下

namespace System.Windows.Interop
{
    /// <summary>
    /// Implements an input provider per hwnd for WM_POINTER messages
    /// </summary>
    internal sealed class HwndPointerInputProvider : DispatcherObject, IStylusInputProvider
    {
        /// <summary>
        /// Creates raw stylus data from the raw WM_POINTER properties
        /// </summary>
        /// <param name="pointerData">The current pointer info</param>
        /// <param name="tabletDevice">The current TabletDevice</param>
        /// <returns>An array of raw pointer data</returns>
        private int[] GenerateRawStylusData(PointerData pointerData, PointerTabletDevice tabletDevice)
        {
            ... // 忽略其他代码
        }

        ... // 忽略其他代码
    }
}

此 GenerateRawStylusData 被调用是这么写的

namespace System.Windows.Interop
{
    /// <summary>
    /// Implements an input provider per hwnd for WM_POINTER messages
    /// </summary>
    internal sealed class HwndPointerInputProvider : DispatcherObject, IStylusInputProvider
    {
        /// <summary>
        /// Processes the latest WM_POINTER message and forwards it to the WPF input stack.
        /// </summary>
        /// <param name="pointerId">The id of the pointer message</param>
        /// <param name="action">The stylus action being done</param>
        /// <param name="timestamp">The time (in ticks) the message arrived</param>
        /// <returns>True if successfully processed (handled), false otherwise</returns>
        private bool ProcessMessage(uint pointerId, RawStylusActions action, int timestamp)
        {
            PointerData data = new PointerData(pointerId);

            ... // 忽略其他代码
                uint cursorId = 0;
                if (UnsafeNativeMethods.GetPointerCursorId(pointerId, ref cursorId))
                {
                    ... // 忽略其他代码
                    GenerateRawStylusData(data, _currentTabletDevice);
                    ... // 忽略其他代码
                }

        }
        ... // 忽略其他代码
    }
}

在 GenerateRawStylusData 方法里面,先通过 PointerTabletDevice 取出支持的 Pointer 的设备属性列表的长度,用于和输入点的信息进行匹配。回忆一下,这部分获取逻辑是在上文介绍到对
GetPointerDeviceProperties
函数的调用提到的,且也说明了此函数拿到的设备属性列表的顺序是非常关键的,设备属性列表的顺序和在后续 WM_POINTER 消息拿到的裸数据的顺序是直接对应的

    /// <summary>
    /// Implements an input provider per hwnd for WM_POINTER messages
    /// </summary>
    internal sealed class HwndPointerInputProvider : DispatcherObject, IStylusInputProvider
    {
        /// <summary>
        /// Creates raw stylus data from the raw WM_POINTER properties
        /// </summary>
        /// <param name="pointerData">The current pointer info</param>
        /// <param name="tabletDevice">The current TabletDevice</param>
        /// <returns>An array of raw pointer data</returns>
        private int[] GenerateRawStylusData(PointerData pointerData, PointerTabletDevice tabletDevice)
        {
            // Since we are copying raw pointer data, we want to use every property supported by this pointer.
            // We may never access some of the unknown (unsupported by WPF) properties, but they should be there
            // for consumption by the developer.
            int pointerPropertyCount = tabletDevice.DeviceInfo.SupportedPointerProperties.Length;

            // The data is as wide as the pointer properties and is per history point
            int[] rawPointerData = new int[pointerPropertyCount * pointerData.Info.historyCount];

            ... // 忽略其他代码
        }

        ... // 忽略其他代码
    }

由每个 Pointer 的属性长度配合总共的历史点数量,即可获取到这里面使用到的
rawPointerData
数组的长度。这部分代码相信大家很好就理解了

接着就是核心部分,调用
GetRawPointerDeviceData
获取最原始的触摸信息,再对原始触摸信息进行解析处理

            int pointerPropertyCount = tabletDevice.DeviceInfo.SupportedPointerProperties.Length;

            // The data is as wide as the pointer properties and is per history point
            int[] rawPointerData = new int[pointerPropertyCount * pointerData.Info.historyCount];

            // Get the raw data formatted to our supported properties
            if (UnsafeNativeMethods.GetRawPointerDeviceData(
                pointerData.Info.pointerId,
                pointerData.Info.historyCount,
                (uint)pointerPropertyCount,
                tabletDevice.DeviceInfo.SupportedPointerProperties,
                rawPointerData))
            {
                ... // 忽略其他代码
            }

在 Pointer 的设计里面,历史点
historyCount
是包含当前点的,且当前点就是最后一个点。这就是为什么这里只需要传入历史点数量即可,换句话说就是历史点最少包含一个点,那就是当前点

由于 Pointer 获取到的点都是相对于屏幕坐标的,这里需要先偏移一下修改为窗口坐标系,代码如下

                // Get the X and Y offsets to translate device coords to the origin of the hwnd
                int originOffsetX, originOffsetY;
                GetOriginOffsetsLogical(out originOffsetX, out originOffsetY);

        private void GetOriginOffsetsLogical(out int originOffsetX, out int originOffsetY)
        {
            Point originScreenCoord = _source.Value.RootVisual.PointToScreen(new Point(0, 0));

            // Use the inverse of our logical tablet to screen matrix to generate tablet coords
            MatrixTransform screenToTablet = new MatrixTransform(_currentTabletDevice.TabletToScreen);
            screenToTablet = (MatrixTransform)screenToTablet.Inverse;

            Point originTabletCoord = originScreenCoord * screenToTablet.Matrix;

            originOffsetX = (int)Math.Round(originTabletCoord.X);
            originOffsetY = (int)Math.Round(originTabletCoord.Y);
        }

        /// <summary>
        /// The HwndSource for WM_POINTER messages
        /// </summary>
        private SecurityCriticalDataClass<HwndSource> _source;

这里的 GetOriginOffsetsLogical 的实现逻辑就是去窗口的 0,0 点,看这个点会在屏幕的哪里,从而知道其偏移量。至于添加的 MatrixTransform 矩阵的 TabletToScreen 则在后文的具体转换逻辑会讲到,这里先跳过

获取到相对于窗口的坐标偏移量之后,即可将其叠加给到每个点上,用于将这些点转换为窗口坐标系。但是在此之前还需要将获取到的
rawPointerData
进行加工。这一个步骤仅仅只是在 WPF 有需求,仅仅只是为了兼容 WISP 获取到的裸数据的方式。其相差点在于通过 Pointer 获取到的
rawPointerData
的二进制数据格式里面,没有带上按钮的支持情况的信息,在 WPF 这边需要重新创建一个数组对
rawPointerData
重新排列,确保每个点的数据都加上按钮的信息数据

这部分处理仅只是为了兼容考虑,让后续的 StylusPointCollection 开森而已,咱就跳着看就好了

                int numButtons = tabletDevice.DeviceInfo.SupportedPointerProperties.Length - tabletDevice.DeviceInfo.SupportedButtonPropertyIndex;

                int rawDataPointSize = (numButtons > 0) ? pointerPropertyCount - numButtons + 1 : pointerPropertyCount;

                // Instead of a single entry for each button we use one entry for all buttons so reflect that in the raw data size
                data = new int[rawDataPointSize * pointerData.Info.historyCount];

                for (int i = 0, j = rawPointerData.Length - pointerPropertyCount; i < data.Length; i += rawDataPointSize, j -= pointerPropertyCount)
                {
                    Array.Copy(rawPointerData, j, data, i, rawDataPointSize);

                    // Apply offsets from the origin to raw pointer data here
                    data[i + StylusPointDescription.RequiredXIndex] -= originOffsetX;
                    data[i + StylusPointDescription.RequiredYIndex] -= originOffsetY;

                    ... // 忽略其他代码
                }

             ... // 忽略其他代码
            return data;

重新拷贝的过程,还将点的坐标更换成窗口坐标系,即以上的
data[i + StylusPointDescription.RequiredXIndex] -= originOffsetX;

data[i + StylusPointDescription.RequiredYIndex] -= originOffsetY;
两个代码

完成获取之后,就将获取到的裸数据给返回了,这就是 GenerateRawStylusData 的内容

在 ProcessMessage 方法里面获取到 GenerateRawStylusData 返回的原始指针信息,即可将其给到 RawStylusInputReport 作为参数,代码如下

                    // Generate a raw input to send to the input manager to start the event chain in PointerLogic
                    Int32[] rawData = GenerateRawStylusData(data, _currentTabletDevice);
                    RawStylusInputReport rsir =
                        new RawStylusInputReport(
                            InputMode.Foreground,
                            timestamp,
                            _source.Value,
                            action,
                            () => { return _currentTabletDevice.StylusPointDescription; },
                            _currentTabletDevice.Id,
                            _currentStylusDevice.Id,
                            rawData)
                        {
                            StylusDevice = _currentStylusDevice.StylusDevice,
                        };

将创建的 RawStylusInputReport 更新到当前的设备,作为设备的最后的指针信息

        private bool ProcessMessage(uint pointerId, RawStylusActions action, int timestamp)
        {

            PointerData data = new PointerData(pointerId);

             ... // 忽略其他代码

                    _currentStylusDevice.Update(this, _source.Value, data, rsir);
             ... // 忽略其他代码
        }

        private SecurityCriticalDataClass<HwndSource> _source;

且还加入到 InputManager 的 ProcessInput 里面,进入 WPF 的框架内的消息调度

        private bool ProcessMessage(uint pointerId, RawStylusActions action, int timestamp)
        {

            PointerData data = new PointerData(pointerId);

             ... // 忽略其他代码

                    _currentStylusDevice.Update(this, _source.Value, data, rsir);
                    // Now send the input report
                    InputManager.UnsecureCurrent.ProcessInput(irea);
             ... // 忽略其他代码
        }

在进入 InputManager 的 ProcessInput 调度消息之前,先看看
_currentStylusDevice.Update
里面的对原始指针信息的解析实现逻辑


_currentStylusDevice.Update
里面的对原始指针信息的解析实现完全是靠 StylusPointCollection 和 StylusPoint 的构造函数实现的

namespace System.Windows.Input.StylusPointer
{
    /// <summary>
    /// A WM_POINTER specific implementation of the StylusDeviceBase.
    /// 
    /// Supports direct access to WM_POINTER structures and basing behavior off of the WM_POINTER data.
    /// </summary>
    internal class PointerStylusDevice : StylusDeviceBase
    {
        /// <summary>
        /// Updates the internal StylusDevice state based on the WM_POINTER input and the formed raw data.
        /// </summary>
        /// <param name="provider">The hwnd associated WM_POINTER provider</param>
        /// <param name="inputSource">The PresentationSource where this message originated</param>
        /// <param name="pointerData">The aggregated pointer data retrieved from the WM_POINTER stack</param>
        /// <param name="rsir">The raw stylus input generated from the pointer data</param>
        internal void Update(HwndPointerInputProvider provider, PresentationSource inputSource,
            PointerData pointerData, RawStylusInputReport rsir)
        {
             ... // 忽略其他代码

            // First get the initial stylus points.  Raw data from pointer input comes in screen coordinates, keep that here since that is what we expect.
            _currentStylusPoints = new StylusPointCollection(rsir.StylusPointDescription, rsir.GetRawPacketData(), GetTabletToElementTransform(null), Matrix.Identity);

             ... // 忽略其他代码
        }
    }
}

这里的
rsir.GetRawPacketData()
是返回上文提到的
GenerateRawStylusData
方法给出的裸数据的拷贝,代码如下

    internal class RawStylusInputReport : InputReport
    {
        /// <summary>
        ///     Read-only access to the raw data that was reported.
        /// </summary>
        internal int[] GetRawPacketData()
        {
            if (_data == null)
                return null;
            return (int[])_data.Clone();
        }

        /// <summary>
        /// The raw data for this input report
        /// </summary>
        int[] _data;

        ... // 忽略其他代码
    }

这里的 GetTabletToElementTransform 包含了一个核心转换,方法代码如下

    internal class PointerStylusDevice : StylusDeviceBase
    {
        /// <summary>
        ///     Returns the transform for converting from tablet to element
        ///     relative coordinates.
        /// </summary>
        internal GeneralTransform GetTabletToElementTransform(IInputElement relativeTo)
        {
            GeneralTransformGroup group = new GeneralTransformGroup();
            Matrix toDevice = _inputSource.Value.CompositionTarget.TransformToDevice;
            toDevice.Invert();
            group.Children.Add(new MatrixTransform(PointerTabletDevice.TabletToScreen * toDevice));
            group.Children.Add(StylusDevice.GetElementTransform(relativeTo));
            return group;
        }

        ... // 忽略其他代码
    }

这里面方法存在重点内容,那就是 PointerTabletDevice 的 TabletToScreen 属性的计算方法。这个矩阵的计算需要用到开始初始化过程的
GetPointerDeviceRects
函数获取 的
displayRect
尺寸,以及
GetPointerDeviceProperties
获取的 X 和 Y 属性描述信息,属性的定义代码如下

        internal Matrix TabletToScreen
        {
            get
            {
                return new Matrix(_tabletInfo.SizeInfo.ScreenSize.Width / _tabletInfo.SizeInfo.TabletSize.Width, 0,
                                   0, _tabletInfo.SizeInfo.ScreenSize.Height / _tabletInfo.SizeInfo.TabletSize.Height,
                                   0, 0);
            }
        }

可以看到这是一个用于缩放的 Matrix 对象,正是
GetPointerDeviceRects
获取的屏幕尺寸以及
GetPointerDeviceProperties
获取的 X 和 Y 属性描述信息构成的 TabletSize 的比值

回顾一下
_tabletInfo
的 SizeInfo 的创建代码,可以看到 TabletSize 完全是由描述符的尺寸决定,代码如下

            // 以下代码在 PointerTabletDeviceInfo.cs 文件中
            // private bool TryInitializeSupportedStylusPointProperties()
            SupportedPointerProperties = new UnsafeNativeMethods.POINTER_DEVICE_PROPERTY[propCount];

            success = UnsafeNativeMethods.GetPointerDeviceProperties(Device, ref propCount, SupportedPointerProperties);

            ... // 忽略其他代码

            // private bool TryInitializeDeviceRects()
            var deviceRect = new UnsafeNativeMethods.RECT();
            var displayRect = new UnsafeNativeMethods.RECT();

            success = UnsafeNativeMethods.GetPointerDeviceRects(_deviceInfo.device, ref deviceRect, ref displayRect);

            if (success)
            {
                // We use the max X and Y properties here as this is more readily useful for raw data
                // which is where all conversions come from.
                SizeInfo = new TabletDeviceSizeInfo
                (
                    new Size(SupportedPointerProperties[StylusPointDescription.RequiredXIndex].logicalMax,
                    SupportedPointerProperties[StylusPointDescription.RequiredYIndex].logicalMax),

                    new Size(displayRect.right - displayRect.left, displayRect.bottom - displayRect.top)
                );
            }

    internal struct TabletDeviceSizeInfo
    {
        public Size TabletSize;
        public Size ScreenSize;

        internal TabletDeviceSizeInfo(Size tabletSize, Size screenSize)
        {
            TabletSize = tabletSize;
            ScreenSize = screenSize;
        }
    }

如此即可使用 TabletToScreen 属性将收到的基于 Tablet 坐标系的裸指针消息的坐标转换为屏幕坐标,再配合 TransformToDevice 取反即可转换到 WPF 坐标系

在以上代码里面,由于传入 GetTabletToElementTransform 的
relativeTo
参数是 null 的值,将导致
StylusDevice.GetElementTransform(relativeTo)
返回一个单位矩阵,这就意味着在 GetTabletToElementTransform 方法里面的
group.Children.Add(StylusDevice.GetElementTransform(relativeTo));
是多余的,也许后续 WPF 版本这里会被我优化掉

回顾一下 StylusPointCollection 的构造函数参数,有用的参数只有前三个,分别是
rsir.StylusPointDescription
传入描述符信息,以及
rsir.GetRawPacketData()
返回裸指针数据,以及
GetTabletToElementTransform(null)
方法返回转换为 WPF 坐标系的矩阵

_currentStylusPoints = new StylusPointCollection(rsir.StylusPointDescription, rsir.GetRawPacketData(), GetTabletToElementTransform(null), Matrix.Identity);

那 StylusPointCollection 的最后一个参数,即上述代码传入的
Matrix.Identity
有什么用途?其实在 StylusPointCollection 的设计里面,第三个参数和第四个参数是二选一的,且第三个参数的优先级大于第四个参数。即在 StylusPointCollection 底层会判断第三个参数是否有值,如果没有值才会使用第四个参数

在 StylusPointCollection 构造函数里面将会对裸 Pointer 数据进行处理,现在 GetRawPacketData 拿到的裸 Pointer 数据的 int 数组里面的数据排列内容大概如下

| X 坐标 | Y 坐标 | 压感(可选)| StylusPointDescription 里面的属性列表一一对应 |
| X 坐标 | Y 坐标 | 压感(可选)| StylusPointDescription 里面的属性列表一一对应 |
| X 坐标 | Y 坐标 | 压感(可选)| StylusPointDescription 里面的属性列表一一对应 |

存放的是一个或多个点信息,每个点的信息都是相同的二进制长度,分包非常简单

进入到 StylusPointCollection 的构造函数,看看其代码签名定义

namespace System.Windows.Input
{
    public class StylusPointCollection : Collection<StylusPoint>
    {
        internal StylusPointCollection(StylusPointDescription stylusPointDescription, int[] rawPacketData, GeneralTransform tabletToView, Matrix tabletToViewMatrix)
        {
            ... // 忽略其他代码
        }
    }
}

在构造函数里面,先调用 StylusPointDescription 的 GetInputArrayLengthPerPoint 方法,获取每个点的二进制长度,代码如下

    public class StylusPointCollection : Collection<StylusPoint>
    {
        internal StylusPointCollection(StylusPointDescription stylusPointDescription, int[] rawPacketData, GeneralTransform tabletToView, Matrix tabletToViewMatrix)
        {
            ... // 忽略其他代码
            int lengthPerPoint = stylusPointDescription.GetInputArrayLengthPerPoint();

            ... // 忽略其他代码
        }
    }

获取到了一个点的二进制长度,自然就能算出传入的
rawPacketData
参数包含多少个点的信息

        internal StylusPointCollection(StylusPointDescription stylusPointDescription, int[] rawPacketData, GeneralTransform tabletToView, Matrix tabletToViewMatrix)
        {
            ... // 忽略其他代码
            int lengthPerPoint = stylusPointDescription.GetInputArrayLengthPerPoint();
            int logicalPointCount = rawPacketData.Length / lengthPerPoint;
            Debug.Assert(0 == rawPacketData.Length % lengthPerPoint, "Invalid assumption about packet length, there shouldn't be any remainder");
            ... // 忽略其他代码
        }

以上代码的
Debug.Assert
就是要确保传入的
rawPacketData
是可以被
lengthPerPoint
即每个点的二进制长度所整除

完成准备工作之后,接下来就可以将
rawPacketData
解出点了,如下面代码所示

            int lengthPerPoint = stylusPointDescription.GetInputArrayLengthPerPoint();
            int logicalPointCount = rawPacketData.Length / lengthPerPoint;

            for (int count = 0, i = 0; count < logicalPointCount; count++, i += lengthPerPoint)
            {
                //first, determine the x, y values by xf-ing them
                Point p = new Point(rawPacketData[i], rawPacketData[i + 1]);

                ... // 忽略其他代码

                int startIndex = 2;

                ... // 忽略其他代码

                int[] data = null;
                int dataLength = lengthPerPoint - startIndex;
                if (dataLength > 0)
                {
                    //copy the rest of the data
                    var rawArrayStartIndex = i + startIndex;
                    data = rawPacketData.AsSpan(rawArrayStartIndex, dataLength).ToArray();
                }

                StylusPoint newPoint = new StylusPoint(p.X, p.Y, StylusPoint.DefaultPressure, _stylusPointDescription, data, false, false);

                ... // 忽略其他代码

                ((List<StylusPoint>)this.Items).Add(newPoint);
            }

以上代码忽略的部分包含了一些细节,如对 Point 的坐标转换,使用
Point p = new Point(rawPacketData[i], rawPacketData[i + 1]);
拿到的点的坐标是属于 Tablet 坐标,需要使用传入的参数转换为 WPF 坐标,如下面代码所示

        internal StylusPointCollection(StylusPointDescription stylusPointDescription, int[] rawPacketData, GeneralTransform tabletToView, Matrix tabletToViewMatrix)
        {
                ... // 忽略其他代码

                Point p = new Point(rawPacketData[i], rawPacketData[i + 1]);
                if (tabletToView != null)
                {
                    tabletToView.TryTransform(p, out p);
                }
                else
                {
                    p = tabletToViewMatrix.Transform(p);
                }

                ... // 忽略其他代码
        }

通过以上的代码就可以看到 StylusPointCollection 构造函数使用了第三个或第四个参数作为变换,如果第三个参数存在则优先使用第三个参数

其他处理的逻辑就是对压感的额外处理,压感作为 StylusPoint 的一个明确参数,需要额外判断处理

                int startIndex = 2; // X 和 Y 占用了两个元素
                bool containsTruePressure = stylusPointDescription.ContainsTruePressure;
                if (containsTruePressure)
                {
                    // 如果有压感的话,压感也需要多占一个元素
                    //don't copy pressure in the int[] for extra data
                    startIndex++;
                }

                StylusPoint newPoint = new StylusPoint(p.X, p.Y, StylusPoint.DefaultPressure, _stylusPointDescription, data, false, false);
                if (containsTruePressure)
                {
                    // 压感必定是第三个元素,有压感则更新压感
                    //use the algorithm to set pressure in StylusPoint
                    int pressure = rawPacketData[i + 2];
                    newPoint.SetPropertyValue(StylusPointProperties.NormalPressure, pressure);
                }

如此即可解包
| X 坐标 | Y 坐标 | 压感(可选)| StylusPointDescription 里面的属性列表一一对应 |
里面前三个元素,其中压感是可选的。后续的
StylusPointDescription 里面的属性列表一一对应
部分需要重新创建 data 数组传入到各个 StylusPoint 里面,代码如下

                int[] data = null;
                int dataLength = lengthPerPoint - startIndex;
                if (dataLength > 0)
                {
                    //copy the rest of the data
                    var rawArrayStartIndex = i + startIndex;
                    data = rawPacketData.AsSpan(rawArrayStartIndex, dataLength).ToArray();
                }

后续对 StylusPoint 获取属性时,即可通过描述信息获取,描述信息获取到值的方式就是取以上代码传入的
data
二进制数组的对应下标的元素,比如触摸点的宽度或高度信息

完成转换为 StylusPointCollection 之后,即可使用
InputManager.UnsecureCurrent.ProcessInput
方法将裸输入信息调度到 WPF 输入管理器

        private bool ProcessMessage(uint pointerId, RawStylusActions action, int timestamp)
        {
             ... // 忽略其他代码
                    InputReportEventArgs irea = new InputReportEventArgs(_currentStylusDevice.StylusDevice, rsir)
                    {
                        RoutedEvent = InputManager.PreviewInputReportEvent,
                    };

                    // Now send the input report
                    InputManager.UnsecureCurrent.ProcessInput(irea);
             ... // 忽略其他代码
        }

进入到 ProcessInput 里面将会走标准的路由事件机制,通过路由机制触发 Touch 或 Stylus 事件,接下来的逻辑看一下调用堆栈即可,和其他的输入事件逻辑差不多

>   Lindexi.dll!Lindexi.MainWindow.MainWindow_TouchDown(object sender, System.Windows.Input.TouchEventArgs e)
    PresentationCore.dll!System.Windows.RoutedEventArgs.InvokeHandler(System.Delegate handler, object target)
    PresentationCore.dll!System.Windows.EventRoute.InvokeHandlersImpl(object source, System.Windows.RoutedEventArgs args, bool reRaised) 
    PresentationCore.dll!System.Windows.UIElement.RaiseEventImpl(System.Windows.DependencyObject sender, System.Windows.RoutedEventArgs args)
    PresentationCore.dll!System.Windows.UIElement.RaiseTrustedEvent(System.Windows.RoutedEventArgs args) 
    PresentationCore.dll!System.Windows.Input.InputManager.ProcessStagingArea()
    PresentationCore.dll!System.Windows.Input.TouchDevice.RaiseTouchDown() 
    PresentationCore.dll!System.Windows.Input.TouchDevice.ReportDown() 
    PresentationCore.dll!System.Windows.Input.StylusTouchDeviceBase.OnDown() 
    PresentationCore.dll!System.Windows.Input.StylusPointer.PointerLogic.PromoteMainDownToTouch(System.Windows.Input.StylusPointer.PointerStylusDevice stylusDevice, System.Windows.Input.StagingAreaInputItem stagingItem)
    PresentationCore.dll!System.Windows.Input.InputManager.RaiseProcessInputEventHandlers(System.Tuple<System.Windows.Input.ProcessInputEventHandler, System.Delegate[]> postProcessInput, System.Windows.Input.ProcessInputEventArgs processInputEventArgs) 
    PresentationCore.dll!System.Windows.Input.InputManager.ProcessStagingArea()
    PresentationCore.dll!System.Windows.Interop.HwndPointerInputProvider.ProcessMessage(uint pointerId, System.Windows.Input.RawStylusActions action, int timestamp) 
    PresentationCore.dll!System.Windows.Interop.HwndPointerInputProvider.System.Windows.Interop.IStylusInputProvider.FilterMessage(nint hwnd, MS.Internal.Interop.WindowMessage msg, nint wParam, nint lParam, ref bool handled) 
    PresentationCore.dll!System.Windows.Interop.HwndSource.InputFilterMessage(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled)

由于我跑的是 Release 版本的 WPF 导致了有一些函数被内联,如从
HwndPointerInputProvider.ProcessMessage

InputManager.ProcessStagingArea
中间就少了
InputManager.ProcessInput
函数,完全的无函数内联的堆栈应该如下

    PresentationCore.dll!System.Windows.Input.InputManager.ProcessStagingArea()
    PresentationCore.dll!System.Windows.Input.InputManager.ProcessInput()
    PresentationCore.dll!System.Windows.Interop.HwndPointerInputProvider.ProcessMessage(uint pointerId, System.Windows.Input.RawStylusActions action, int timestamp)

如下面代码是 ProcessInput 函数的代码

    public sealed class InputManager : DispatcherObject
    {
        public bool ProcessInput(InputEventArgs input)
        {
            ... // 忽略其他代码
            PushMarker();
            PushInput(input, null);
            RequestContinueProcessingStagingArea();

            bool handled = ProcessStagingArea();
            return handled;
        }
    }

进入到 ProcessStagingArea 方法会执行具体的调度逻辑,用上述触摸按下的堆栈作为例子,将会进入到 PointerLogic 的 PostProcessInput 方法里面,由 PostProcessInput 方法调用到 PromoteMainToOther 再到 PromoteMainToTouch 最后到 PromoteMainDownToTouch 方法。只不过中间的几个方法被内联了,直接从堆栈上看就是从 RaiseProcessInputEventHandlers 到 PromoteMainDownToTouch 方法,堆栈如下

PresentationCore.dll!System.Windows.Input.StylusPointer.PointerLogic.PromoteMainDownToTouch(...)
PresentationCore.dll!System.Windows.Input.InputManager.RaiseProcessInputEventHandlers(...)

核心触发按下的代码就在 PromoteMainDownToTouch 里,其代码大概如下

        private void PromoteMainDownToTouch(PointerStylusDevice stylusDevice, StagingAreaInputItem stagingItem)
        {
            PointerTouchDevice touchDevice = stylusDevice.TouchDevice;

            ... // 忽略其他代码

            touchDevice.OnActivate();
            touchDevice.OnDown();
        }

从上文可以知道,在 HwndPointerInputProvider 的 ProcessMessage 里面调用了
_currentStylusDevice.Update
方法时,就将输入的数据存放到 PointerStylusDevice 里面

后续的逻辑就和
WPF 模拟触摸设备
提到的使用方法差不多,只是数据提供源是从 PointerStylusDevice 提供。如果大家对进入到 InputManager 的后续逻辑感兴趣,可参考
WPF 通过 InputManager 模拟调度触摸事件
提供的方法自己跑一下

更多触摸请看
WPF 触摸相关