在多DPI系统上,VSTO自定义任务窗格显示内容两次

14

我正在使用VSTO构建一个办公插件。在具有不同DPI设置的多个显示器的系统上,我的自定义任务窗格的内容在较高DPI设置的监视器上绘制了两次:

输入图像描述

只有较小的版本实际响应用户输入。较大的版本似乎只是一个放大的图像。

我尝试过调整不同的DPI相关设置,例如:

  • 我的用户控件中的AutoScaleMode。我尝试了所有选项,但没有改变。
  • 使用SetProcessDpiAwareness将进程设置为DPI感知或非DPI感知状态。我尝试了所有选项,但没有改变。
  • 使用app.manifest并将dpiAware设置为truefalse。没有改变。

新的Web Addins没有这个问题。此外,内部任务窗格也没有这个问题。

这是一个已知的问题吗?我该怎么解决这个问题?


您能否使用常规的Windows窗体应用程序重现此问题? - Eugene Astafiev
@EugeneAstafiev:不,我不是。 - Daniel Hilgarth
如果您调整任务窗格的大小,它会消失吗? - Chris
不,它只是重新绘制两者。 - Daniel Hilgarth
4个回答

5
这似乎是Office产品处理WM_DPICHANGED消息的方式中存在的一个漏洞。应用程序应该枚举所有子窗口并在响应消息时重新缩放它们,但它在处理插件窗格时出现了问题。
你可以尝试禁用DPI缩放来解决此bug。你说你已经尝试调用了SetProcessDpiAwareness函数,但是该函数在应用程序设置了DPI感知后会失败,而你使用的应用程序显然已经设置了它,因为它适用于父窗口。那么你应该调用SetThreadDpiAwarenessContext,就像这个C#包装器中所示。不幸的是,我没有Win10多监视器设置来测试它,但是应用程序正在运行时应该可以工作。尝试这个插件,它有一个按钮可以设置线程DPI感知上下文,看看是否适用于你。

应用程序钩子方法

由于SetThreadDpiAwarenessContext可能在您的系统上不可用,解决问题的一种方法是使主窗口忽略WM_DPICHANGED消息。这可以通过安装应用程序钩子来更改消息或者通过子类化窗口来实现。应用程序钩子是一种稍微简单一些且风险较小的方法。基本思路是拦截主应用程序的GetMessage并将 WM_DPICHANGED更改为WM_NULL,这将使应用程序丢弃该消息。缺点是,此方法仅适用于已发布的消息,但WM_DPICHANGED应该是其中之一。

因此,要安装应用程序钩子,您的插件代码应如下所示:

public partial class ThisAddIn
{
    public enum HookType : int
    {
        WH_JOURNALRECORD = 0,
        WH_JOURNALPLAYBACK = 1,
        WH_KEYBOARD = 2,
        WH_GETMESSAGE = 3,
        WH_CALLWNDPROC = 4,
        WH_CBT = 5,
        WH_SYSMSGFILTER = 6,
        WH_MOUSE = 7,
        WH_HARDWARE = 8,
        WH_DEBUG = 9,
        WH_SHELL = 10,
        WH_FOREGROUNDIDLE = 11,
        WH_CALLWNDPROCRET = 12,
        WH_KEYBOARD_LL = 13,
        WH_MOUSE_LL = 14
    }

    delegate IntPtr HookProc(int code, IntPtr wParam, IntPtr lParam);
    [DllImport("user32.dll", SetLastError = true)]
    static extern IntPtr SetWindowsHookEx(HookType hookType, HookProc lpfn, IntPtr hMod, uint dwThreadId);
    [DllImport("user32.dll")]
    static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);


    [StructLayout(LayoutKind.Sequential)]
    public struct POINT
    {
        public int X;
        public int Y;
    }
    public struct MSG
    {
        public IntPtr hwnd;
        public uint message;
        public IntPtr wParam;
        public IntPtr lParam;
        public uint time;
        public POINT pt;
    }

    HookProc cbGetMessage = null;

    private UserControl1 myUserControl1;
    private Microsoft.Office.Tools.CustomTaskPane myCustomTaskPane;
    private void ThisAddIn_Startup(object sender, System.EventArgs e)
    {
        this.cbGetMessage = new HookProc(this.MyGetMessageCb);
        SetWindowsHookEx(HookType.WH_GETMESSAGE, this.cbGetMessage, IntPtr.Zero, (uint)AppDomain.GetCurrentThreadId());

        myUserControl1 = new UserControl1();
        myCustomTaskPane = this.CustomTaskPanes.Add(myUserControl1, "My Task Pane");
        myCustomTaskPane.Visible = true;


    }

    private IntPtr MyGetMessageCb(int code, IntPtr wParam, IntPtr lParam)
    {
        unsafe
        {
            MSG* msg = (MSG*)lParam;
            if (msg->message == 0x02E0)
                msg->message = 0;
        }

        return CallNextHookEx(IntPtr.Zero, code, wParam, lParam);
    }

    private void ThisAddIn_Shutdown(object sender, System.EventArgs e)
    {
    }

    #region VSTO generated code

    private void InternalStartup()
    {
        this.Startup += new System.EventHandler(ThisAddIn_Startup);
        this.Shutdown += new System.EventHandler(ThisAddIn_Shutdown);
    }

    #endregion
}

请注意,这是大部分未经测试的代码,如果它能够阻止 WM_DPICHANGED 消息,您可能需要确保在应用程序退出之前删除钩子来进行清理。

子类方法

如果你想要阻止的信息不是发布到窗口而是发送,应用程序挂钩方法将无法使用,主窗口必须被子类化。这次我们将在用户控件中放置代码,因为在调用SetWindowLong时需要完全初始化主窗口。

因此,要对Power Point窗口进行子类化,我们的用户控件(位于插件内部)可能如下所示(请注意,我在此使用OnPaint,但您可以使用任何保证在调用SetWindowLong时窗口已初始化的方法):

public partial class UserControl1 : UserControl
{
    const int GWLP_WNDPROC = -4;
    [DllImport("user32", SetLastError = true)]
    extern static IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam);
    [DllImport("user32", SetLastError = true)]
    extern static IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr lpNewLong);
    [DllImport("user32", SetLastError = true)]
    extern static IntPtr SetWindowLong(IntPtr hWnd, int nIndex, IntPtr lpNewLong);
    delegate IntPtr WindowProc(IntPtr hwnd, uint uMsg, IntPtr wParam, IntPtr lParam);
    private IntPtr origProc = IntPtr.Zero;
    private WindowProc wpDelegate = null;
    public UserControl1()
    {
        InitializeComponent();
        this.Paint += UserControl1_Paint;

    }

    void UserControl1_Paint(object sender, PaintEventArgs e)
    {
        if (origProc == IntPtr.Zero)
        {
            //Subclassing
            this.wpDelegate = new WindowProc(MyWndProc);
            Process process = Process.GetCurrentProcess();
            IntPtr wpDelegatePtr = Marshal.GetFunctionPointerForDelegate(wpDelegate);
            if (IntPtr.Size == 8)
            {
                origProc = SetWindowLongPtr(process.MainWindowHandle, GWLP_WNDPROC, wpDelegatePtr);
            }
            else
            {
                origProc = SetWindowLong(process.MainWindowHandle, GWLP_WNDPROC, wpDelegatePtr);
            }
        }
    }


    //Subclassing
    private IntPtr MyWndProc(IntPtr hwnd, uint uMsg, IntPtr wParam, IntPtr lParam)
    {
        if (uMsg == 0x02E0) //WM_DPICHANGED
            return IntPtr.Zero;
        IntPtr retVal = CallWindowProc(origProc, hwnd, uMsg, wParam, lParam);
        return retVal;
    }
}

@mnistic:如果你知道如何做到这一点,那就太好了! - Daniel Hilgarth
你用这种方法有任何成功经验吗?我们为一家名为UpSlide的公司工作,该公司开发Office插件,但我们还没有找到代码修复的方法。请注意,如果您使用最新的Office 2016和Windows 10版本,Office中会有一个新选项: https://upslide.zendesk.com/hc/en-us/articles/360003084614-UpSlide-panes-are-not-displayed-properly 这是我们发送给客户的文章。这个解决方案并不是非常令人满意,因为兼容模式会使Office变得丑陋(模糊)。 - misterfrb
很不幸,我仍然没有Win10机器来测试它(无论是在工作还是家里),所以你可能想联系原帖发布者。 (但我已经使用WM_DISPLAYCHANGE进行了测试,并且它可以完美地运行)此外,如果您恰好拥有Win10多监视器设置,那么测试应该足够简单,只需将“子类化方法”部分的代码复制/粘贴到新的PowerPoint插件项目中即可。 - mnistic
@mnistic 我可以尝试你的解决方案,它很有效,直到手动调整窗格宽度或最大化/最小化窗口。你的解决方案成功地防止了这个错误,因为它捕获了窗口改变屏幕时发送的消息,但是当接收到其他消息时,窗格仍然会被破坏。顺便说一下,我们在更新的机器上进行的最新测试(Windows 10,Office 2016 Insider)显示,即使在“最佳外观”模式下,Windows表单窗格的错误似乎已经修复。我们仍然有一些问题,这些问题涉及托管WPF控件的窗格,只与特定的缩放更改有关。 - misterfrb
啊,好的。谢谢你回报! - mnistic
显示剩余8条评论

3
这是一个假设,希望能指向根本原因;问题在于 VSTO Office 应用程序中的消息泵被过滤了。
可能是一个误导,因为我从未见过 WndProc 消息导致双重渲染,但我以前也从未见过双重渲染!
然而,设置焦点问题和/或不可点击的控件让我想起了这种行为。
最初,我遇到了我的一个 Excel Add-Ins 的奇怪问题: BUG: 无法选择 DatePicker 上的日期,它们落在浮动的 VSTO Add-In 之外 Hans Passant 确定了根本原因:
引用:

永远没有问题的是,您依赖于 Excel 中的消息泵来分派 Windows 消息,这些消息使这些控件响应输入。这在 WPF 和 Winforms 中都会出错,它们有自己的分派循环,在将消息传递到窗口之前过滤消息。

我已经用这些信息回答了一些问题。此问答展示了一种纠正消息泵分派的方法,例如Excel CustomTaskPane with WebBrowser control - keyboard/focus issues
protected override void WndProc(ref Message m)
{
  const int NotifyParent = 528; //might be different depending on problem
  if(m.Msg == NotifyParent && !this.Focused)
  {
    this.Focus();
  }
  base.WndProc(ref m);
}

如果这不是根本原因,至少你可以把它从故障排除步骤中划掉,这是一种“离经叛道”的诊断技术。

如果可能的话,我希望有一个 [mcve] 来帮助您解决问题。


编辑

我无法重现它!这是针对特定电脑的。尝试升级您的视频驱动程序或使用不同视频卡的机器。以下是我的视频卡规格:

名称 Intel(R) HD Graphics 520
适配器类型 Intel(R) HD Graphics Family
驱动程序
igdumdim64.dll,igd10iumd64.dll,igd10iumd64.dll,igdumdim32,igd10iumd32,igd10iumd32
驱动程序 c:\windows\system32\drivers\igdkmd64.sys (20.19.15.4326, 7.44 MB (7,806,352 字节), 19/06/2016 11:32 PM)

enter image description here


好的,请标记办公室技术人员吗?好的,现在太晚了,我将在12小时后尝试。您信任这段代码不起作用吗?您得到了什么结果,我的可能会不同? - Jeremy Thompson
请看我的编辑,“office tech”我只是想标记问题Excel、PP、Word和WPF或Winform(它是PowerPoint吗?我的复制是一个Excel - Winform AddIn - .Net 4.0)。如果你能给我一个复制,我很乐意帮忙。另外,我现在正在使用Windows 7,也许这就是为什么我无法复制的原因。 - Jeremy Thompson
目前使用的是PowerPoint,无论是WinForms还是WPF都没有关系。两者都会出现这种情况。然而,根据我的理解,Win7不支持不同显示器的不同DPI值,所以这可能是你无法重现的原因。 - Daniel Hilgarth
感谢您提供的复现,这是一个视频卡问题。请联系 Microsoft 支持团队 PSS/CSS。如果这是一个错误,那么它是免费的。驱动程序应该负责此问题。 - Jeremy Thompson
我认为这不是VSTO问题,而是更底层的问题。请谨慎考虑我的建议,很抱歉我现在没有设备来重现它。我相信你,依我之见,这是一个DPI的错误。 - Jeremy Thompson
显示剩余7条评论

2
由于您的插件正在托管环境中运行,因此对进程级别进行更改不会有任何帮助。但是,有Win32 API可用于处理子窗口。一个进程可以在其顶层窗口之间拥有不同的DPI感知上下文。自周年更新以来可用(Windows 10,版本1703)。
我自己没有测试过,所以我只能指向最相关的方向。 "当您想要将对话框或对话框中的HWND排除在自动DPI缩放之外时,您可以使用SetDialogDpiChangeBehavior / SetDialogControlDpiChangeBehavior"
更多信息请参见:https://blogs.windows.com/buildingapps/2017/04/04/high-dpi-scaling-improvements-desktop-applications-windows-10-creators-update/#bEKiRLjiB4dZ7ft9.97 多年来,我已经涉足低级Win32对话框了 - 但我相当确定您可以在任何窗口句柄上使用这些API,而无需创建实际的对话框。如果我没记错的话,对话框和普通窗口只是在默认消息循环处理程序和一些不同的默认窗口样式方面有所区别。
从外观上看,似乎您在插件中使用了WPF。DPI感知和WPF肯定有它的时刻。但是,在元素主机中托管WPF可能会使您对DPI问题有更多控制。特别是当应用Win32 API时,能够使用元素主机的窗口句柄并覆盖其接收到的WIN32消息。
希望这有所帮助。

谢谢你的提示,但我不确定如何应用它。在我创建截图的演示中,我没有使用WPF,只是一个简单的WinForms用户控件。 - Daniel Hilgarth

0
尝试将以下代码添加到您的窗体构造函数中:
[DllImport("User32.dll")]
public static extern int SetProcessDPIAware();

此外,您可能会发现创建DPI感知应用程序线程有所帮助。


不,这也不会改变任何事情。问题在于PowerPoint已经具备了DPI感知功能。 - Daniel Hilgarth
你尝试过将 .net 4.7 作为目标框架吗?这样有帮助吗? - Eugene Astafiev
我试过了,但没有帮助。但这对我的项目来说也不是一个解决方案,因为我们的目标是4.0,因为企业客户仍在旧的Win 7安装上运行。 - Daniel Hilgarth
值得注意的是:在大多数情况下,在 UI 循环(Application.Run)启动之前,您必须执行此方法(或其从 shcore.dll 的后继 setprocessdpiawareness)。 - caesay

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接