QML 缩放 —— 不同设备的适配显示解决方案

本文翻译自 Qt 的官方文章 Scalability,本文只是提供简单的翻译,本人的英文水平有限,有不合理的地方希望大家交流指正。

可伸缩性

当我们开发的应用程序要适配到不同的移动设备的时候,我们常常会面临如下的挑战:

  1. 移动设备平台支持具有不同屏幕配置的设备:尺寸,宽高比,方向和密度。
  2. 不同的平台有不同的UI约定,你需要在每一个平台上满足用户的期望。

Qt Quick (亦称 QML译者注) 使我们可以开发在不同类型设备(如平板电脑和手机)上运行的应用程序。 特别是,这些程序可以应付不同的屏幕配置。 当然,为了实现在每个目标平台上有最佳的用户体验,我们也需要对我们的程序进行一些细节上的调整。

我们在遇到如下情形时需要考虑可伸缩性:

  1. 希望将应用程序部署到多个设备平台(如Android和iOS)或多个设备屏幕配置。
  2. 希望为初步部署后可能出现在市场上的新设备做好准备。

我们可以使用 Qt Quick 来实现可扩展的应用程序:

  1. 使用 Qt Quick Controls 或Qt Quick Controls 2 提供的 UI 控件集。
  2. 使用可以调整其项目的大小的 Qt Quick Layouts 来定义布局。
  3. 使用属性绑定来实现未被布局覆盖的用例。 例如,要在具有低和高像素密度的屏幕上显示图像的替代版本,或根据当前屏幕方向自动调整视图内容。
  4. 选择一个参考设备并计算一个缩放比例,以便图像、字体大小以及边距能够与实际屏幕大小相适应。
  5. 使用文件选择器加载平台相关的资源文件。
  6. 通过使用Loader在需要时再加载组件。

在设计应用程序时,请考虑以下模式:

  1. 视图的内容在所有屏幕尺寸上应尽可能相似,除非它包含可扩展的内容区域。 如果您使用 Qt Quick Controls 中的 ApplicationWindow QML 类型,它将根据其内容项的大小自动计算窗口大小。 如果您使用 Qt Quick Layouts 来定位内容项目,Qt Quick Layouts 会自动调整推送给他们的项目的大小。
  2. 在较小的设备中作为整个页面显示的组件,可以在较大的设备中作为整个页面布局的一部分。 因此,可以考虑在大设备中使用分割器组件(将在小设备中显示的整个页面放入分割器 QML 文件中),而在较小的设备中,视图将仅包含该组件的实例。 在较大的设备上,可能有足够的空间来使用动态加载来显示其他项目。 例如,在电子邮件查看器中,如果屏幕足够大,则可以并排显示电子邮件列表视图和电子邮件阅读器视图。
  3. 对于游戏来说,我们通常可以创建一个不缩放的游戏视图,以免给较大屏幕上的玩家带来导致游戏不公平的优势。 一个解决方案是定义一个固定的区域,以适应屏幕的最小支持的宽高比(通常为3:2),并添加一些在 4:3 或 16:9 的屏幕上将被隐藏的仅用于装饰的内容。

动态调整应用程序的大小

Qt Quick Controls 提供了一组可在 Qt Quick 中创建用户界面的 UI 控件。通常,我们将 ApplicationWindow控件声明为应用程序的根项目。ApplicationWindow 增加了以平台独立的方式定位其他控件(如MenuBarToolBarStatusBar)的便利。当计算实际窗口的有效大小约束时,ApplicationWindow 使用内容项的大小约束作为输入。

除了定义应用程序窗口的标准部分的控件之外,还提供了用于创建视图和菜单以及呈现或接收用户输入的控件。您可以使用 Qt Quick Controls Styles 将自定义样式应用于预定义的控件。 有关使用样式的示例,请参阅 Qt Quick Controls - Touch Gallery

Qt Quick Controls(如ToolBar)不提供自己的布局,但要求您定位其内容。 为此,您可以使用Qt Quick Layouts。

动态布局屏幕控件

Qt Quick Layouts 提供了使用 RowLayoutColumnLayoutGridLayout QML 类型在行,列或网格中布置屏幕控件的方法。

我们可以使用 Layout QML 类型来将附加属性附加到已经被放置在布局的项目中。例如,我们可以指定最小值,最大值,以及项目高度,宽度和尺寸的首选值。

布局确保在窗口和屏幕调整大小时,始终使用最大可用空间来适当地缩放我们的 UI。

一个使用场景的示例如:将 GridLayout 类型根据屏幕方向将其用作行或列布局:

显示效果

如下代码片段使用了 flow 属性来设置网格的布局流,当屏幕的宽度大于高度的时候,从左到右布局(成为一行),否则,将从上到下布局(成为一列):

ApplicationWindow {
    id: root
    visible: true
    width: 480
    height: 620

    GridLayout {
        anchors.fill: parent
        anchors.margins: 20
        rowSpacing: 20
        columnSpacing: 20
        flow:  width > height ? GridLayout.LeftToRight : GridLayout.TopToBottom
        Rectangle {
            Layout.fillWidth: true
            Layout.fillHeight: true
            color: "#5d5b59"
            Label {
                anchors.centerIn: parent
                text: "Top or left"
                color: "white"
            }
        }
        Rectangle {
            Layout.fillWidth: true
            Layout.fillHeight: true
            color: "#1e1b18"
            Label {
                anchors.centerIn: parent
                text: "Bottom or right"
                color: "white"
            }
        }
    }
}

根据屏幕的调整实时地调整和重新计算可能带来性能和功耗上的问题。 例如,移动和嵌入式设备可能没有重新计算每个帧的动画对象的大小和位置所需的性能。 如果在使用布局时遇到性能问题,请考虑使用其他方法,例如绑定。

以下是在使用布局时的一些注意事项

  1. 不要去绑定已经在布局中的项目的 x,y,width 或 height 属性,因为这将与 Layout 的目标相冲突,也会导致循环绑定问题。
  2. 不要定义需要根据属性变化经常重新进行计算的复杂 JavaScript 函数。这将导致性能不佳,特别是在动画运行期间。
  3. 不要对容器尺寸或子项目的尺寸做出预想的限定。尝试使用灵活的布局定义,以便适应可能的空间变化。即不将容器和子项目的大小限制太死,而是将其放在布局中,让其与布局相对存在。
  4. 如果我们希望我们的设计在像素级别上分毫不差,那么请勿使用布局。因为布局中的项目将根据可用空间自动调整大小并进行定位。在这个过程中,我们的设计可能发生改变。但是,这种在像素级别上分毫不差的代价通常是牺牲可移植性。

使用绑定

如果Qt Quick Layouts不符合您的需求,我们可以试试使用属性绑定。属性绑定使对象能够自动更新其属性以响应其他对象属性的变化或某些外部事件。

当一个对象的属性被分配一个值时,它可以被分配一个静态值,或者被绑定到一个 JavaScript 表达式。在前一种情况下,除非为该属性分配新值,否则该属性的值将不会更改。在后一种情况下,创建属性绑定,并且每当表达式的值更改时,属性的值将由 QML 引擎自动更新。

这种定位是最高效的。 然而,不断检测和重新计算 JavaScript 表达式的改变将带来性能上的开销。

我们可以使用绑定来处理没有自动支持的平台上的低和高像素密度变化(如macOS和iOS)。以下代码片段使用 Screen.PixelDensity 附加属性指定不同的图像以在具有低,高或正常像素密度的屏幕上显示:

Image {
    source: {
        if (Screen.PixelDensity < 40)
        "image_low_dpi.png"
        else if (Screen.PixelDensity > 300)
        "image_high_dpi.png"
        else
        "image.png"
        }
}

在macOS和iOS上,您可以为图标和图像提供两倍大小和 @ 2x 标识符的替代资源,并将它们放置在资源文件中。 在Retina显示屏上,@ 2x 版本会自动使用。

例如,以下代码片段将尝试在Retina显示器上加载artwork@2x.png:

Image {
    source: "artwork.png"
}

处理像素密度

某些QML类型(如 ImageBorderImageText)会根据为它们指定的属性自动缩放。如果没有指定图像的宽度和高度,它将自动使用 source 属性指定的源图像的大小。默认情况下,指定宽度和高度会使图像缩放到该大小。可以通过设置 fillMode 属性来更改此行为,从而允许图像被拉伸和平铺。但是,在高 DPI 显示屏上,原始图像尺寸可能会显得太小。

BorderImage 用于通过缩放或平铺每个图像的部分来创建图像的边框。它将源图像分解为9个按照属性值进行缩放或平铺的区域。然而,重叠的角落是根本没有缩放的,这可能使得结果在高 DPI 显示屏上看起来不那么令人满意。

Text QML 类型会尝试自适应确定需要多少空间并相应地设置宽度和高度属性,除非它的宽高被明确地设置。fontPointSize 属性可以以设备无关的方式设置点大小。然而,指定 font 属性用点大小,但是指定其他尺寸使用像素大小会导致问题,因为点大小与显示密度无关。这种情况下,在低 DPI 显示屏上看起来正确的字符串的范围可能在高 DPI 显示屏上变得太小,因此导致文本被剪切而显示不全。

支持平台的高 DPI 支持水平和技术使用的平台各不相同。以下部分介绍了在高DPI显示屏上缩放屏幕内容的不同方法。

有关Qt 和受支持平台中的高 DPI 支持的更多信息,请参阅 High DPI Displays

macOS 和 iOS 上的高 DPI 缩放

在 macOS 和 iOS 上,应用程序使用高 DPI 扩展,这是传统 DPI 缩放的替代方案。在传统的方法中,应用程序会被提供一个用于乘以字体大小,布局等的 DPI 值。在新的方法中,操作系统为 Qt 提供了缩放比例,用于缩放图形输出:分配较大的缓冲区并设置缩放变换。

这种方法的优点是矢量图形和字体自动缩放,现有应用程序倾向于未修改。然而,对于光栅内容,需要高分辨率的替代资源。

QtQuickQtWidgets 堆栈实现了缩放,以及 QtGui 和 Cocoa 平台插件的一般支持。

OS 缩放窗口,事件和桌面几何图形。Cocoa 平台插件将缩放比例设置为 QWindow::devicePixelRatio() 或 QScreen::devicePixelRatio() 以及后备存储。

对于 QtWidgets, QPainter 从后台存储器中拾取 devicePixelRatio() ,并将其当作缩放比例。

然而,在 OpenGL 中,像素总是设备像素。例如,传递给 glViewport() 的几何图形需要通过 devicePixelRatio() 进行缩放。

与 UI 的其余部分相比,指定的字体大小(以点或像素为单位)不会更改,字符串保留其相对大小。字体被缩放为绘画的一部分,因此无论是以点或像素指定大小,尺寸为 12 的字体都会有效地以 2 倍缩放为尺寸为 24 的字体。px 单位被解释为与设备无关的像素,以确保在高 DPI 显示屏上字体不显得更小。

计算缩放比例

我们可以选择一个高 DPI 参考设备并计算一个缩放比例,以便图像、字体大小以及边距能够与实际屏幕大小相适应。

以下代码段使用 Nexus 5 Android 设备的 DPI,高度和宽度的参考值, QRect 类返回的实际屏幕尺寸以及 qApp 全局指针返回的屏幕的逻辑DPI值,以计算缩放比例,用于图像尺寸和边距(m_ratio),以及用于字体大小(m_ratioFont):

qreal refDpi = 216.;
qreal refHeight = 1776.;
qreal refWidth = 1080.;
QRect rect = QGuiApplication::primaryScreen()->geometry();
qreal height = qMax(rect.width(), rect.height());
qreal width = qMin(rect.width(), rect.height());
qreal dpi = QGuiApplication::primaryScreen()->logicalDotsPerInch();
m_ratio = qMin(height/refHeight, width/refWidth);
m_ratioFont = qMin(height*refDpi/(dpi*refHeight), width*refDpi/(dpi*refWidth));

对于合理的缩放比例,高度和宽度值必须根据参考设备的默认方向进行设置,在上面的示例中就是纵向方向。

以下代码片段将字体缩放比例设置为1,因为如果它小于1,会导致字体大小变得太小:

int tempTimeColumnWidth = 600;
int tempTrackHeaderWidth = 270;
if (m_ratioFont < 1.) {
    m_ratioFont = 1;

我们应该尝试使用目标设备来查找需要额外计算的情况。一些屏幕可能太短或狭窄,为了适应所有规划好的内容,因此需要自己的布局。例如,我们可能需要隐藏或替换非典型宽高比的屏幕上的某些内容,例如宽高比为 1:1 的屏幕。

缩放比例可以应用于 QQmlPropertyMap 中的所有尺寸以缩放图像,字体和边距:

m_sizes = new QQmlPropertyMap(this);
m_sizes->insert(QLatin1String("trackHeaderHeight"), QVariant(applyRatio(270)));
m_sizes->insert(QLatin1String("trackHeaderWidth"), QVariant(applyRatio(tempTrackHeaderWidth)));
m_sizes->insert(QLatin1String("timeColumnWidth"), QVariant(applyRatio(tempTimeColumnWidth)));
m_sizes->insert(QLatin1String("conferenceHeaderHeight"), QVariant(applyRatio(158)));
m_sizes->insert(QLatin1String("dayWidth"), QVariant(applyRatio(150)));
m_sizes->insert(QLatin1String("favoriteImageHeight"), QVariant(applyRatio(76)));
m_sizes->insert(QLatin1String("favoriteImageWidth"), QVariant(applyRatio(80)));
m_sizes->insert(QLatin1String("titleHeight"), QVariant(applyRatio(60)));
m_sizes->insert(QLatin1String("backHeight"), QVariant(applyRatio(74)));
m_sizes->insert(QLatin1String("backWidth"), QVariant(applyRatio(42)));
m_sizes->insert(QLatin1String("logoHeight"), QVariant(applyRatio(100)));
m_sizes->insert(QLatin1String("logoWidth"), QVariant(applyRatio(286)));

m_fonts = new QQmlPropertyMap(this);
m_fonts->insert(QLatin1String("six_pt"), QVariant(applyFontRatio(9)));
m_fonts->insert(QLatin1String("seven_pt"), QVariant(applyFontRatio(10)));
m_fonts->insert(QLatin1String("eight_pt"), QVariant(applyFontRatio(12)));
m_fonts->insert(QLatin1String("ten_pt"), QVariant(applyFontRatio(14)));
m_fonts->insert(QLatin1String("twelve_pt"), QVariant(applyFontRatio(16)));

m_margins = new QQmlPropertyMap(this);
m_margins->insert(QLatin1String("five"), QVariant(applyRatio(5)));
m_margins->insert(QLatin1String("seven"), QVariant(applyRatio(7)));
m_margins->insert(QLatin1String("ten"), QVariant(applyRatio(10)));
m_margins->insert(QLatin1String("fifteen"), QVariant(applyRatio(15)));
m_margins->insert(QLatin1String("twenty"), QVariant(applyRatio(20)));
m_margins->insert(QLatin1String("thirty"), QVariant(applyRatio(30)));

以下代码段中的函数将缩放比例应用于字体,图像和边距:

int Theme::applyFontRatio(const int value)
{
    return int(value * m_ratioFont);
}

int Theme::applyRatio(const int value)
{
    return qMax(2, int(value * m_ratio));
}

根据平台加载文件资源

我们可以使用 QQmlFileSelectorQFileSelector 应用于 QML 文件加载。这使您能够根据运行应用程序的平台加载替代资源。例如,我们可以使用 + Android 文件选择器在 Android 设备上运行时加载不同的图像文件。

我们可以使用文件选择器和单例对象来访问特定平台上的对象的单个实例。

文件选择器是静态的,并执行文件结构,其中特定于平台的文件存储在以平台命名的子文件夹中。 如果您需要一个更加动态的解决方案来按需加载 UI 的部件,则可以使用 Loader 组件。

目标平台可以以各种方式自动加载不同显示密度的替代资源。在iOS上,@2x 文件名后缀用于指示图像的高 DPI 版本。Image QML 类型和 QIcon 类自动加载 @2x 版本的图像和图标(如果提供)。QImageQPixmap 类自动将 @2x 版本的图像的 devicePixelRatio 设置为 2,但是我们需要添加实际使用 @2x 版本的代码:

if ( QGuiApplication::primaryScreen()->devicePixelRatio() >= 2 ) {
    imageVariant = "@2x";
} else {
    imageVariant = "";
}

Android 定义了可以创建替代资源的广义屏幕尺寸(small,normal,large,xlarge)和密度(ldpi,mdpi,hdpi,xhdpi,xxhdpi 和 xxxhdpi)。Android 会在运行时检测当前的设备配置,并为应用程序加载适当的资源。然而,从Android 3.2(API级别13)开始,这些大小组已被弃用,有利于基于可用屏幕宽度来管理屏幕尺寸的新技术。

按需加载组件

Loader 可以加载 QML 文件(使用source属性)或 Component 对象(使用 sourceComponent 属性)。对于延迟组件的创建直到需要才有用。例如,在需要时再创建组件,或者由于性能原因,不应该创建不必要的组件时。

您也可以使用加载程序对特定平台上不需要部分UI的情况做出反应,在这些平台不支持某些功能时。应用程序正在运行的设备上不去显示不需要的视图的时候,我们可以将视图隐藏并使用加载器在其位置显示其他内容。

切换方向

Screen.orientation 附加属性包含从加速度计(如果可用)获取的屏幕当前方向。在台式机上,此值通常不会改变。

如果 primaryOrientation 属性的值随方向改变,则表示屏幕会自动旋转显示的所有内容,具体取决于我们如何握住设备。如果方向更改,而 primaryOrientation 不更改,设备可能不会旋转自身的显示内容。在这种情况下,我们可能需要使用 Item.rotationItem.transform 来旋转内容。

应用程序顶级页面定义和可重用组件定义应为布局结构使用一个 QML 布局定义。该单一定义应包括用于单独的设备方向和宽高比的布局设计。原因是在方向切换期间的性能至关重要,因此,当方向改变时,确保两个方向所需的所有组件都被加载是一个好主意。

相反,如果您选择使用加载程序来加载单独方向所需的其他 QML,则应执行彻底测试,因为这将影响方向更改的性能。

为了启用方向之间的布局动画,锚定义必须驻留在相同的包含组件中。因此,页面或组件的结构应包含一组共同的子组件,一组常用的锚定义,以及一组状态(在 StateGroup 中定义),表示该组件支持的不同宽高比。

如果页面中包含的组件需要托管在许多不同形式因子的定义中,则视图的布局状态应取决于页面(其直接容器)的宽高比。类似地,组件的不同实例可能位于 UI 中的多个不同容器中,因此其布局状态应由其父级的宽高比来确定。结论是布局状态应该始终遵循直接容器的宽高比(而不是当前设备屏幕的“方向”)。

在每个布局状态中,我们应该使用本地 QML 布局定义来定义项目之间的关系。有关详细信息,请参阅下文。在状态之间(由顶级方向改变触发)过渡期间,在锚定布局的情况下,AnchorAnimation 元素可用于控制转换。在某些情况下,我们也可以使用例如 NumberAnimation 于项目的 width 属性。记住在每个动画帧中避免复杂的JavaScript计算。在大多数情况下,使用简单的锚定义和锚点动画可以帮助我们。

还有一些特定情况下的开发建议:

  1. 我们是否有一个页面在横屏和竖屏之间看起来完全不同,包括所有的子项目是不同的?对于每个页面,我们可以定义两个具有单独的布局的子组件,并使每个项目中的一个或多个项目在不同状态时设置其透明度为零。您可以通过简单地对 opacity 属性应用NumberAnimation 来实现交叉渐变动画。

  2. 我们是否有一个页面在纵向和横向之间共享30%或更多相同的布局内容?在这种情况下,请考虑使用具有横向和纵向状态的一个组件,以及 opacity(或 position)取决于方向状态的单独子项的集合。这将使我们能够在方向切换时,对共享的项目的使用布局动画,而其他项目则则使用淡入/淡出或开/关屏幕动画。

  3. 如果手持设备上有两页需要同时在屏幕上,例如在较大的外形设备上,该怎么办?在这种情况下,需要注意使我们的视图组件将不再占据全屏。因此,要记住的最重要一点是所有组件(特别是列表委托项目)的尺寸应该取决于包含这些组件的容器组件的宽度,而不是屏幕的宽度。 在这种情况下,可能需要在 Component.onCompleted() 中设置宽度,以确保在设置值之前已经构造了列表项委托。

  4. 如果出现必须同时加载两个方向的组件,但是又特别占用内存怎么办呢?如果我们不能将视图的两个版本一次保存在内存中,则必要时可以使用 Loader ,但请注意布局切换期间交叉渐变动画的性能。一个解决方案是定义两个"splash screen" 项目作为页面的子项,然后在旋转的时候进行淡入淡出。我们可以使用 Loader 加载另一个正在将实际模型数据加载到其子项的子组件,并在 Loader 加载完成时运行淡出淡入效果。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,219评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,363评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,933评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,020评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,400评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,640评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,896评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,597评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,327评论 1 244
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,581评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,072评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,399评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,054评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,849评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,672评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,585评论 2 270

推荐阅读更多精彩内容