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

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

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

  • 移动设备平台支持具有不同屏幕配置的设备:尺寸,宽高比,方向和密度。
  • 不同的平台有不同的UI约定,你需要在每一个平台上满足用户的期望。
  • Qt Quick (亦称 QML 译者 注) 使我们可以开发在不同类型设备(如平板电脑和手机)上运行的应用程序。 特别是,这些程序可以应付不同的屏幕配置。 当然,为了实现在每个目标平台上有最佳的用户体验,我们也需要对我们的程序进行一些细节上的调整。

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

  • 希望将应用程序部署到多个设备平台(如Android和iOS)或多个设备屏幕配置。
  • 希望为初步部署后可能出现在市场上的新设备做好准备。
  • 我们可以使用 Qt Quick 来实现可扩展的应用程序:

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

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

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

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

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

    动态布局屏幕控件

    Qt Quick Layouts 提供了使用 RowLayout ColumnLayout GridLayout 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"
    

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

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

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

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