Qt之高DPI顯示器(二) - 自適配解決方案分析


原文鏈接:Qt之高DPI顯示器(二) - 自適配解決方案分析

最近一直在處理高DPI問題,也花費了不少功夫,前前后后使用了多種解決方案,各種方案也都有利弊,筆者最終采用了自適配方案,雖然復雜一些,但是結果可控。這里把處理的過程記錄下來,留給有同樣需求的同學

一、回顧

上一篇文章Qt之高DPI顯示器(一) - 解決方案整理講述了筆者處理高DPI顯示的一系列分析過程,為了更好的閱讀和排版,其中有一些實驗方案沒有具體寫出,即使寫出來也沒有多大用處,而且會影響大家閱讀。

本篇文章將會接着上一篇文章的最后一小節-自適配高DPI進行講解,由於內容比較多,而且整個解決方案代碼量也會相當大,因此文章中也只會涉及到整個DPI適配架構的核心和一些關鍵代碼,有疑問歡迎提問

上一篇文章提到了T窗口,那么什么是T窗口呢!下面我們來具體分析。

這里筆者貼一個適配完成以后的TWidget類,大家可以先分析分析,也可以猜猜看,每一處代碼的具體含義。所有代碼細節筆者后邊會具體分析每一處細節

//函數聲明
//xxx.h
#define CreateTWidget() CreateObject(Widget)
class TWidget : public QWidget, public ICallDPIChanged
{
	Q_OBJECT

public:
	TWidget(float scale, QWidget * parent = nullptr);
	TWidget(QWidget * parent = nullptr);//不建議使用
	TWidget(QWidget * parent, Qt::WindowFlags f);//不建議使用
	~TWidget();

public:
	//重寫大小變化相關函數
	DECLARE_RESIZE();
	void setLayout(QLayout * layout);

public:
	//QWidget
	virtual bool nativeEvent(const QByteArray &eventType, void *message, long *result)override;

	//ICallDPIChanged
	DECLARE_DPI();

	//TWidget
	virtual void AdjustReiszeHandle();

	DECLARE_DPI_SYMBOL;

protected:
	WidgetResizeHandler resize_handler;// 用於支持放大縮小 拖拽 等功能

private:
	TigerUILib::ReiszeActions m_sizeActions;
	QSize m_size;
	QSize m_minimumSize;
	QSize m_maximumSize;
	ICallDPIChanged * m_pLayout = nullptr;//DPI發生變化時 通知布局
};

//函數實現
//xxx.cpp
TWidget::TWidget(float scale, QWidget * parent)
	: QWidget(parent)
	, dpi_scale(scale)
{
}

TWidget::TWidget(QWidget * parent /*= nullptr*/)
	: QWidget(parent)
{
	dpi_scale = WINDOW_SCALE;
}

TWidget::TWidget(QWidget *parent, Qt::WindowFlags f)
	: QWidget(parent, f)
{
	dpi_scale = WINDOW_SCALE;
}

TWidget::~TWidget()
{
	DPIHelper()->RemoveDPIRecord(WINDOW_WINID);
}

void TWidget::setLayout(QLayout * layout)
{
	WIDGET_RELEATE_LAYOUTS(layout);

	__super::setLayout(layout);
}

DEFINE_RESIZE(Widget);
DEFINE_DPI(Widget);

bool TWidget::nativeEvent(const QByteArray &eventType, void *message, long *result)
{
	MSG* pMsg = reinterpret_cast<MSG*>(message);

	switch (pMsg->message)
	{
	case WM_DPICHANGED:
	{
		DWORD dpi = LOWORD(pMsg->wParam);
		WId id = WINDOW_WINID;
		if (DPIHelper()->DPIChanged(dpi, id))
		{
			ScaleChanged(DPIHelper()->GetDPIScale(id));
			RefrushSheet(this, id);
		}
	}
	}

	return __super::nativeEvent(eventType, message, result);
}

void TWidget::ScaleChanged(float scale)
{
	DEFINTE_SCALE_RESIZE(Widget);
	if (m_pLayout)
	{
		m_pLayout->ScaleChanged(scale);
	}
	AdjustReiszeHandle();
}

void TWidget::AdjustReiszeHandle()
{
	if (resize_handler.isWidgetMoving())
	{
		resize_handler.dpiChanged(WINDOW_SCALE);
	}
}

二、框架說明

用一段話描述一下DPI適配方案?

答:筆者提到的DPI適配方案其實原理很簡單,沒有想象中那么復雜,方案也是中規中矩,其中遵守以下這么幾條大的原則

  1. 首先就是window窗體自己去監測自身所在屏幕DPI發生變化,發生變化時通知布局進行縮放
  2. 局部縮放后,然后對自身所包含的widget窗體和布局進行縮放
  3. 不在布局中的窗體需要單獨去控制縮放

是不是說起來很簡單,但是要實現這么一個流程還是有一些難度的,首先考慮的就是效率,如果做完效率跟不上那么一切都是瞎扯。

為了更好的效率,筆者也是做了不需要的優化,優化的內容不在本篇文章中進行討論,后續會單獨分出一篇文章說明

下面是兩個DPI適配框架的核心接口類,分別是DPI發生變化時的回調接口類和DPi管理接口類

struct ICallDPIChanged
{
	virtual void ScaleChanged(float scale) = 0;
	virtual WId GetWID() const = 0;
	virtual void SetScale(float scale) = 0;
};

#define STANDARD_DPI 96.0
struct IDPIHelper
{
	virtual bool DPIChanged(unsigned short, WId) = 0;
	virtual void RemoveDPIRecord(WId) = 0;//移除指定native窗體的DPI記錄 一般用於native窗體析構時
	virtual float GetDPIScale(WId) const = 0;
	virtual float GetOldDPIScale(WId) const = 0;
	virtual QString GetStyleSheet(WId) const = 0;//獲取指定DPI下的樣式表
	virtual float GetScaleNumber(float, WId) const = 0;//獲取指定DPI下的數值 縮放后數值
	virtual QList<WId> GetAllWindowID() const = 0;//獲取所有自己加載過皮膚的窗口ID

	//優化接口 主要是為了適配用戶主機只有一種DPI時使用
	virtual bool IsOnlyOneDPI() const = 0;//獲取用戶主機是否只有一種DPI
	virtual void RefrushDPIRecords() = 0;//顯示器數量發生了變化 刷新歷史顯示器DPI記錄
	virtual void SetDefaultScale(float scale) = 0;//設置缺省DPI值 當顯示器dpi只有一種時刷新
	virtual float GetDefaultScale() const = 0;//獲取缺省DPI縮放值 只有當機器上所有的顯示器為統一dpi時起作用
};

IDPIHelper * GetDPIHelper();
#define DPIHelper() GetDPIHelper()

1、ICallDPIChanged

dpi變化時回調類,當dpi發生變化時,通過該接口類中的ScaleChanged方法進行處理變動,比如說第一小節中的TWidget類,我們也重寫了這個接口,在該接口中我們對窗體進行了大小適配和布局適配

對象聲明中的函數聲明使用了宏進行包裝因此沒有直接顯示出來

void TWidget::ScaleChanged(float scale)
{
	DEFINTE_SCALE_RESIZE(Widget);
	if (m_pLayout)
	{
		m_pLayout->ScaleChanged(scale);
	}
	AdjustReiszeHandle();//如果窗體正在被拖拽需要適配拖拽的位置
}

2、IDPIHelper

IDPIHelper是整個DPi適配的核心模塊,他負責整個DPI調度的核心功能,包括:DPI改變檢測、獲取指定window窗體縮放比、獲取指定window窗體的qss內容和獲取指定數值在不同DPI下的實際數值等。除過以上核心接口以外,筆者為了優化DPI適配效果,還增加了一系列優化接口,主要是針對用戶主機只有一種DPI時所作的性能提升。

由於篇幅原因,這里把一些關鍵實現節點列出來

1、dpi變化入口

如下是dpi發生變化實現接口,函數中干了三件事

  1. 首先監測dpi是否正在發生了變化,如果發生了變化則更新緩存中的window窗體的dpi縮放比
  2. 接着讀取window窗體中的qss標識生成新的qss樣式字符串
  3. 通知所有懸浮窗體管理器,適配所有懸浮窗體

懸浮窗體指沒有布局的窗體,當懸浮窗體的父窗體dpi發生變化時,相應的懸浮窗體也需要進行適配

bool CDPIHelper::DPIChanged(unsigned short dpi, WId id)
{
#ifndef HIGHDPI_ENABLE
	return false;
#endif
	float scale = dpi / STANDARD_DPI;

	RefrushDPIRecords();

	if (m_pWindowScale.contains(id))
	{
		if (m_pWindowScale[id] == scale)
		{
			return false;
		}

		m_pWindowOldScale[id] = m_pWindowScale[id];
	}

	m_pWindowScale[id] = scale;

	QWidget * window = QWidget::find(id);
	m_strQssFile = window->property(QSS_FIlE).toString();
	if (m_strQssFile.isEmpty())
	{
		m_strQssFile = DEFAULT_QSS_FILE;
	}
	else
	{
		if (m_strQssFile.endsWith(DEFAULT_QSS_SUFFIX) == false)
		{
			m_strQssFile.append(DEFAULT_QSS_SUFFIX);
		}
	}

	RefrushTimesSheet(Skin::TypeDefault, id);
	RefrushTimesSheet(Skin::TypeLight, id);

	CFloatingWidgetMgr::getInstance()->dpiChanged(id, scale);
	return true;
}

2、獲取指定DPI下的qss內容

void CDPIHelper::RefrushTimesSheet(Skin::SKIN_TYPE skin, WId id)
{
	float scale = GetDPIScale(id);

	int times = (int)(scale + 0.5001);//幾倍圖

	//如果基礎qss不存在 則需要從硬盤中讀取  
	//讀取時按照向上取整進行讀取qss文件
	//如果高分屏qss不存在 則讀取一倍qss文件
	if (m_StyleSheets[skin].size() < times)
	{
		m_StyleSheets[skin].resize(times);
	}

	std::wstring filePath = ImagePath::GetSkinFilePath(skin, m_strQssFile.toStdWString(), times);
	if (QFile::exists(QString::fromStdWString(filePath)) == false)
	{
		filePath = ImagePath::GetSkinFilePath(skin, m_strQssFile.toStdWString());
	}
	QFile qss(QString::fromStdWString(filePath));
	qss.open(QFile::ReadOnly);
	if (qss.isOpen())
	{
		QString btnstylesheet = QObject::tr(qss.readAll());
		m_StyleSheets[skin][times - 1][SCALE_ENLARGE(m_strQssFile, scale)] = btnstylesheet;
		qss.close();
	}

	Q_ASSERT(m_StyleSheets[skin].size() > times - 1);
	
	//更新緩存中的換膚文件
	m_StyleSheetMap[skin][SCALE_ENLARGE(m_strQssFile, scale)] =
		QtTigerHelper::ScaleSheet(m_StyleSheets[skin][times - 1][SCALE_ENLARGE(m_strQssFile, scale)], scale);
}

3、懸浮窗體管理器

大多數的窗體都是在布局中完成的,但是也有一小部分的窗口不在布局中,需要單獨去適配,這個時候就需要使用CFloatingWidgetMgr布局管理器。

/**
* 簡介:懸浮窗口管理器 負責在DPI發生變化時通知懸浮窗口
		支持如下類型的懸浮窗口:
		TFrame TPushButton TLabel TTableView TWidget TDialog TMainWindow
*/
class CFloatingWidgetMgr : public QObject
{
	Q_OBJECT

public:
	static CFloatingWidgetMgr * getInstance();

public:
	void addWidget(QWidget * widget);

	//dpi helper call
	void dpiChanged(WId id, float scale);

private:
	QSet<ICallDPIChanged *> m_pWidgets;
};

懸浮窗體適配高DPI也很簡單,只需要把自己加入到懸浮窗體管理器中即可,是不是也很簡單。

CFloatingWidgetMgr::getInstance()->addWidget(xxx);

三、方案分析

既然我們要重寫Qt控件的非virtual接口,那么這個行為在C++語法上應該叫覆蓋,要想調用我們覆蓋的函數,使用多態肯定是不行的,聰明的你肯定也想到了,我們在使用界面類時,只能使用T打頭的控件類聲明對象,這樣就會調用我們覆蓋后的接口

上一篇文章大致說過,要自適配高DPI我們需要適配四個項目,分別是窗口大小、字體大小、間距和圖標,那么接下來就開始我們的分析過程

1、窗口大小

要適配軟件窗口大小,我們總共需要重寫如下14個和大小相關函數,而且這只是大小相關的函數,也就是QWidget的接口,其他更復雜的接口需要針對具體的類去重寫

void resize(int w, int h);void resize(const QSize &); void setFixedHeight(int w); 
void setFixedWidth(int w);void setFixedSize(int w, int h);void setFixedSize(const QSize &s);
void setMinimumSize(const QSize &);void setMinimumSize(int minw, int minh);
void setMinimumHeight(int minh);void setMinimumWidth(int minw);
void setMaximumSize(const QSize &);void setMaximumSize(int maxw, int maxh);
void setMaximumHeight(int minh);void setMaximumWidth(int minw);

Qt的界面類我粗略估計了下,至少有幾十個,如果每一個類都需要去適配,那么工作量可想而知,因此筆者想了一個辦法,做了一系列宏,像下面代碼這樣,只需要在我們想要適配的類中添加宏即可

//函數聲明
#define DECLARE_RESIZE()\
	void resize(int w, int h);void resize(const QSize &); void setFixedHeight(int w); \
	void setFixedWidth(int w);void setFixedSize(int w, int h);void setFixedSize(const QSize &s);\
	void setMinimumSize(const QSize &);void setMinimumSize(int minw, int minh);\
	void setMinimumHeight(int minh);void setMinimumWidth(int minw);\
	void setMaximumSize(const QSize &);void setMaximumSize(int maxw, int maxh);\
	void setMaximumHeight(int minh);void setMaximumWidth(int minw);\

實際使用過程類似第一小節那樣,非常簡單。

函數聲明有了,接下來就是函數實現,方法類似,筆者還是寫了一個宏來適配相關放大函數,代碼下下面這樣

//函數實現
#define DEFINE_RESIZE(name)\
	void T##name::resize(int w, int h){	m_sizeActions |= TigerUILib::RA_Resize;	float scale = dpi_scale;	m_size = QSize(w, h);;__super::resize(m_size.width() * scale, m_size.height() * scale);}\
	void T##name::resize(const QSize & size){	m_sizeActions |= TigerUILib::RA_Resize;	float scale = dpi_scale;m_size = size;__super::resize(m_size * scale);}\
	void T##name::setFixedHeight(int h){m_sizeActions |= TigerUILib::RA_FixedHeight;float scale = dpi_scale;m_size.setHeight(h);__super::setFixedHeight(m_size.height() * scale);}\
	void T##name::setFixedWidth(int w){m_sizeActions |= TigerUILib::RA_FixedWidth;float scale = dpi_scale;m_size.setWidth(w);__super::setFixedWidth(m_size.width() * scale);}\
	void T##name::setFixedSize(int w, int h){m_sizeActions |= TigerUILib::RA_FixedSize;float scale = dpi_scale;		m_size = QSize(w, h);	__super::setFixedSize(m_size.width() * scale, m_size.height() * scale);}\
	void T##name::setFixedSize(const QSize & size){m_sizeActions |= TigerUILib::RA_FixedSize;float scale = dpi_scale;	m_size = size;	__super::setFixedSize(m_size * scale);}\
	void T##name::setMinimumSize(const QSize & size){m_sizeActions |= TigerUILib::RA_MinimumSize;float scale = dpi_scale;m_minimumSize = size;	__super::setMinimumSize(m_minimumSize * scale);}\
	void T##name::setMinimumSize(int w, int h){m_sizeActions |= TigerUILib::RA_MinimumSize;float scale = dpi_scale;	m_minimumSize = QSize(w, h);	__super::setMinimumSize(m_minimumSize.width() * scale, m_minimumSize.height() * scale);}\
	void T##name::setMinimumHeight(int h){m_sizeActions |= TigerUILib::RA_MinimumHeight;float scale = dpi_scale;m_minimumSize.setHeight(h);	__super::setMinimumHeight(m_minimumSize.height() * scale);}\
	void T##name::setMinimumWidth(int w){m_sizeActions |= TigerUILib::RA_MinimumWidth;float scale = dpi_scale;		m_minimumSize.setWidth(w);	__super::setMinimumWidth(m_minimumSize.width() * scale);}\
	void T##name::setMaximumSize(const QSize & size){m_sizeActions |= TigerUILib::RA_MaximumSize;float scale = dpi_scale;	m_maximumSize = size;	__super::setMaximumSize(m_maximumSize * scale);}\
	void T##name::setMaximumSize(int w, int h){m_sizeActions |= TigerUILib::RA_MaximumSize;float scale = dpi_scale;	m_maximumSize = QSize(w, h);	__super::setMaximumSize(m_maximumSize.width() * scale, m_maximumSize.height() * scale);}\
	void T##name::setMaximumHeight(int h){m_sizeActions |= TigerUILib::RA_MaximumHeight;float scale = dpi_scale;	m_maximumSize.setHeight(h);	__super::setMaximumHeight(m_maximumSize.height() * scale);}\
	void T##name::setMaximumWidth(int w){m_sizeActions |= TigerUILib::RA_MaximumWidth;float scale = dpi_scale;	m_maximumSize.setWidth(w);	__super::setMaximumWidth(m_maximumSize.width() * scale);}

動態調整

仔細閱讀DEFINE_RESIZE宏中的任意一個函數,就能發現每一個函數中都有一個TigerUILib::WidgetAction標記,表示該對象的此函數是否被調用過,標記之后有一個好處,那就是當我們軟件所在屏幕的DPI發生變化時可以有針對性的去調用相關函數,下面是一個簡單的測試代碼。

if (testflag("setfixedWidth"))
{
    setFixedWidth(width * scale);
}

說到這里有必要介紹下DEFINTE_SCALE_RESIZE宏,如下代碼,就不解釋了一看應該都會明白

#define DEFINTE_SCALE_RESIZE(name)\
	if (m_sizeActions.testFlag(TigerUILib::RA_FixedWidth)){Q##name::setFixedWidth(m_size.width() * scale);}\
	if (m_sizeActions.testFlag(TigerUILib::RA_FixedHeight)){Q##name::setFixedHeight(m_size.height() * scale);}\
	if (m_sizeActions.testFlag(TigerUILib::RA_FixedSize)){Q##name::setFixedSize(m_size * scale);}\
	if (m_sizeActions.testFlag(TigerUILib::RA_Resize)){	QSize newSize = m_size * scale;if(minimumSize().width() > newSize.width()){Q##name::setMinimumSize(newSize);}Q##name::resize(newSize);}\
	if (m_sizeActions.testFlag(TigerUILib::RA_MinimumSize)){Q##name::setMinimumSize(m_minimumSize * scale);}\
	if (m_sizeActions.testFlag(TigerUILib::RA_MinimumHeight)){Q##name::setMinimumHeight(m_minimumSize.height() * scale);}\
	if (m_sizeActions.testFlag(TigerUILib::RA_MinimumWidth)){Q##name::setMinimumWidth(m_minimumSize.width() * scale);}\
	if (m_sizeActions.testFlag(TigerUILib::RA_MaximumSize)){Q##name::setMaximumSize(m_maximumSize * scale);}\
	if (m_sizeActions.testFlag(TigerUILib::RA_MaximumHeight)){Q##name::setMaximumHeight(m_maximumSize.height() * scale);}\
	if (m_sizeActions.testFlag(TigerUILib::RA_MaximumWidth)){ Q##name::setMaximumWidth(m_maximumSize.width() * scale); }\
	dpi_scale = scale;

2、字體大小

Qt程序我們的字體大小都是在qss文件中進行標記,那么適配高DPI也就很簡單了,只需要把96dpi下的數字大小按比例進行放大即可。

知道方法后,做起來就很簡單了,只需要寫一個字符串替換函數,把qss中的數值按比例放大即可,方法如下。

數值放大時有一個小技巧,那就是要做一個平滑處理,1.49px當做1px處理 1.5px當做2px,意思就是說在做數字當大的過程中,可能會出現小數,我們的原則是數值放大后加上0.50001然后取整數部分。

QString QtTigerHelper::ScaleSheet(const QString & sheet, float scale)
{
	if (sheet.isEmpty())
	{
		return sheet;
	}

	//1倍圖時不需要做任何處理
	if (scale == 1.0)
	{
		return sheet;
	}

	//放大字體
	QString tempStyle = sheet;
	QRegExp rx("\\d+px", Qt::CaseInsensitive);
	rx.setMinimal(true);
	int index = -1;
	while ((index = rx.indexIn(tempStyle, index + 1)) >= 0)
	{
		int capLen = rx.cap(0).length() - 2;
		QString snum = tempStyle.mid(index, capLen);
		snum = QString::number(qRound(snum.toInt() * scale));
		tempStyle.replace(index, capLen, snum);
		index += snum.length();
		if (index > tempStyle.size() - 2)
		{
			break;
		}
	}

	return tempStyle;
}

3、間距

Qt中的布局有2中方式可以設置,可以在代碼中通過接口設置,也可以通過qss進行設置,當然了這兩種情況都需要適配。

布局的margin

記錄調用了哪些設置大小的函數,在dpi發生變化時重新設置一遍,類似於窗口大小變化時所作調整

if (testflag("margin"))
{
    setContextMargin(...);
}

padding和margin

方式和放大字體一樣,可以通過統一的時機去處理

讀取原有qss文件,使用正則表達式生成scale版本的新qss文件。

4、圖標

圖標替換是一個相對來說比較復雜的事情,這里有必要細說一下。

首先是工程中需要額外添加2x和3x分辨率的圖標,1x圖標為正常情況下使用的圖標,2x和3x圖標分別是高分辨率下的圖標

替換圖標有兩種情況,一種是使用qss方式貼的圖,另一種是自繪貼的圖

qss方式

預先生成高分辨率下的整數倍xxx_2x.qss和xxx_3x.qss文件,需要強調一下,2x和3xqss文件中的字號還是一倍程序中的字號,實際使用的時候在動態放大,如果想要程序的效率高一些可能還需要做一些緩存

自繪

如果是自繪文字和圖片,那就需要自己控制縮放比,和圖片壓縮系數

縮放比: 繪制文字時需要放大的比例,計算方式為當前dpi值除以96.0,結果是一個浮點數,比如說1.5

壓縮系數: 繪制圖片的時候這里有一個小竅門,當我們繪制縮放比為小數情況時,需要使用距離較近的整數圖片進行壓縮繪制,這樣的情況我們就需要使用壓縮系數進行動態調整繪制圖片的大小

float ImagePath::GetStretchFactor(float scale)
{
	if (scale < 1.5)
	{
		return scale;
	}
	else if (scale < 2.5)
	{
		return scale / 2;
	}
	else if (scale < 3.5)
	{
		return scale / 3;
	}
	else//缺省為3倍圖拉伸
	{
		return scale / 3;
	}
}

以上就是DPI適配方案的大致思路了,因為篇幅原因沒有針對每一個widget和layout進行詳細說明,有需要的可以私聊。

四、相關文章

Qt之高DPI顯示器(一) - 解決方案整理

PPI vs. DPI: 有什么區別?

High DPI Desktop Application Development on Windows

PROCESS_DPI_AWARENESS Enumeration

SetProcessDPIAware function:Win Vista開始支持的接口

SetProcessDpiAwareness function:Win8.1開始支持的接口

關於Windows高DPI的一些簡單總結

如何開發新的Qt 5.7高DPI每監視器DPI感

值得一看的優秀文章:

  1. 財聯社-產品展示
  2. 廣聯達-產品展示
  3. Qt定制控件列表
  4. 牛逼哄哄的Qt庫

如果您覺得文章不錯,不妨給個 打賞,寫作不易,感謝各位的支持。您的支持是我最大的動力,謝謝!!!




很重要--轉載聲明

  1. 本站文章無特別說明,皆為原創,版權所有,轉載時請用鏈接的方式,給出原文出處。同時寫上原作者:朝十晚八 or Twowords

  2. 如要轉載,請原文轉載,如在轉載時修改本文,請事先告知,謝絕在轉載時通過修改本文達到有利於轉載者的目的。



免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM