可扩展性

如何开发在不同屏幕配置和用户界面惯例的设备上扩展良好的应用程序。

当你为多个不同的移动设备平台开发应用程序时,你将面临以下挑战:

  • 移动设备平台支持具有不同屏幕配置的设备:尺寸、宽高比、方向和密度。

  • 不同的平台有不同的用户界面惯例,您需要满足每个平台上用户的期望。

Qt Quick 使您能够开发可以在不同类型的设备上运行的应用程序,例如平板电脑和手机。特别是,它们可以应对不同的屏幕配置。然而,为了为每个目标平台创建最佳的用户体验,总是需要一定量的修复和优化。

在以下情况下,您需要考虑可扩展性:

  • 您希望将应用程序部署到多个设备平台,例如Android和iOS,或多个设备屏幕配置。

  • 您希望为初始部署后可能出现在市场上的新设备做好准备。

使用Qt Quick实现可扩展的应用程序:

  • 使用Qt Quick Controls设计用户界面,这些控件提供了一系列的UI控件。

  • 使用Qt Quick Layouts定义布局,这些布局可以调整其项目的大小。

  • 使用属性绑定来实现布局未涵盖的用例。例如,在低像素密度和高像素密度的屏幕上显示图像的替代版本,或根据当前屏幕方向自动调整视图内容。

  • 选择一个参考设备,并计算一个缩放比例,用于调整图像和字体大小以及边距以适应实际屏幕尺寸。

  • 使用文件选择器加载平台特定的资源。

  • 通过使用Loader按需加载组件。

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

  • 视图的内容在所有屏幕尺寸上可能非常相似,但具有扩展的内容区域。如果您使用Qt Quick Controls中的ApplicationWindow QML类型,它将根据其内容项的大小自动计算窗口大小。如果您使用Qt Quick Layouts来定位内容项,它们将自动调整推送到它们的项的大小。

  • 在较小设备上的整个页面内容可以形成较大设备上布局的一个组件元素。因此,考虑将其作为一个单独的组件(即在单独的QML文件中定义),在较小设备上,视图将仅包含该组件的一个实例。在较大设备上,可能有足够的空间使用加载器来显示额外的项目。例如,在电子邮件查看器中,如果屏幕足够大,可以并排显示电子邮件列表视图和电子邮件阅读器视图。

  • 对于游戏,您通常希望创建一个不缩放的游戏板,以免为拥有更大屏幕的玩家提供不公平的优势。一个解决方案是定义一个安全区域,该区域适合具有最小支持宽高比(通常为3:2)的屏幕,并在4:3或16:9屏幕上隐藏的空间中添加仅用于装饰的内容。

动态调整应用程序窗口大小

Qt Quick Controls 提供了一套UI控件,用于在Qt Quick中创建用户界面。通常,您会声明一个ApplicationWindow控件作为应用程序的根项目。ApplicationWindow以便于定位其他控件,如MenuBar、ToolBar和StatusBar,以平台无关的方式进行。ApplicationWindow在计算实际窗口的有效大小约束时,使用内容项的大小约束作为输入。

除了定义应用程序窗口标准部分的控件外,还提供了用于创建视图和菜单的控件,以及向用户展示或接收输入的控件。您可以使用Qt Quick Controls Styles对预定义的控件应用自定义样式。

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

动态布局屏幕控件

Qt Quick 布局提供了使用 RowLayout、ColumnLayout 和 GridLayout QML 类型在行、列或网格中排列屏幕控件的方法。这些 QML 类型的属性决定了它们的布局方向以及单元格之间的间距。

您可以使用Qt Quick Layouts QML类型将附加属性附加到推送到布局的项目中。例如,您可以为项目的高度、宽度和大小指定最小值、最大值和首选值。

布局确保在窗口和屏幕调整大小时,您的用户界面能够正确缩放,并始终使用可用的最大空间。

GridLayout 类型的一个特定用例是根据屏幕方向将其用作行或列。

../_images/scalability-gridlayout.png

以下代码片段使用 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属性,因为这会与布局的目标冲突,并导致绑定循环。

  • 不要定义经常被评估的复杂JavaScript函数。这会导致性能不佳,特别是在动画过渡期间。

  • 不要对容器大小或子项的大小做出假设。尝试制定灵活的布局定义,以适应可用空间的变化。

  • 如果您希望设计达到像素级完美,请不要使用布局。内容项将根据可用空间自动调整大小和位置。

使用绑定

如果Qt Quick布局不符合您的需求,您可以回退到使用属性绑定。绑定使对象能够自动更新其属性,以响应其他对象中属性的变化或某些外部事件的发生。

当为对象的属性赋值时,可以为其分配一个静态值,或者绑定到一个JavaScript表达式。在前一种情况下,属性的值不会改变,除非为该属性分配一个新值。在后一种情况下,会创建一个属性绑定,并且每当评估的表达式的值发生变化时,QML引擎会自动更新属性的值。

这种定位方式是最具动态性的。然而,不断评估JavaScript表达式会带来性能成本。

您可以使用绑定来处理不支持自动处理低和高像素密度的平台(如Android、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"
        }
    }

在Android、macOS和iOS上,您可以通过使用相应的标识符(例如@2x@3x@4x)为图标和图像提供更高分辨率的替代资源,并将它们放置在资源文件中。系统会自动选择与屏幕像素密度匹配的版本进行使用。

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

Image {
    source: "artwork.png"
}

处理像素密度

一些QML类型,例如Image、BorderImage和Text,会根据为它们指定的属性自动缩放。如果未指定Image的宽度和高度,它会自动使用源图像的大小,该大小通过source属性指定。默认情况下,指定宽度和高度会导致图像缩放到该大小。可以通过设置fillMode属性来更改此行为,允许图像被拉伸和平铺。然而,原始图像大小在高DPI显示器上可能显得太小。

BorderImage 用于通过缩放或平铺图像的各个部分来创建图像边框。它将源图像分割成9个区域,这些区域根据属性值进行缩放或平铺。然而,角落部分完全不缩放,这可能导致在高DPI显示器上的效果不够理想。

一个Text QML类型尝试确定需要多少空间,并相应地设置widthheight属性,除非它们被显式设置。fontPointSize属性以设备无关的方式设置点大小。然而,以点为单位指定字体大小,以像素为单位指定其他大小会导致问题,因为点与显示密度无关。在低DPI显示器上看起来正确的字符串周围的框架在高DPI显示器上可能会变得太小,导致文本被裁剪。

高DPI支持的水平以及支持平台所使用的技术因平台而异。以下部分描述了在高DPI显示器上缩放屏幕内容的不同方法。

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

macOS 和 iOS 上的高 DPI 缩放

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

这种方法的优势在于矢量图形和字体可以自动缩放,现有的应用程序通常无需修改即可工作。然而,对于光栅内容,需要高分辨率的替代资源。

缩放功能已在QtQuick和QtWidgets堆栈中实现,同时在QtGui和Cocoa平台插件中提供了通用支持。

操作系统缩放窗口、事件和桌面几何。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));
}

当目标设备的屏幕尺寸差异不大时,此技术会给出合理的结果。如果差异很大,请考虑使用不同的参考值创建几种不同的布局。

根据平台加载文件

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

你可以使用文件选择器与单例对象一起访问特定平台上的单个对象实例。

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

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

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

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

按需加载组件

一个加载器可以加载一个QML文件(使用source属性)或一个组件对象(使用sourceComponent属性)。它对于延迟组件的创建直到需要时非常有用。例如,当组件应该按需创建时,或者出于性能原因不应该不必要地创建组件时。

您还可以使用加载器来应对在特定平台上不需要部分UI的情况,因为该平台不支持某些功能。与其在应用程序运行的设备上显示不需要的视图,您可以确定该视图是隐藏的,并使用加载器在其位置显示其他内容。

切换方向

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

如果primaryOrientation跟随orientation,这意味着屏幕会根据你持有设备的方式自动旋转显示的所有内容。如果即使primaryOrientation没有改变,方向也发生了变化,设备可能不会旋转其自身的显示。在这种情况下,你可能需要使用Item.rotation或Item.transform来旋转你的内容。

应用程序的顶层页面定义和可重用组件定义应使用一个QML布局定义来定义布局结构。这个单一的定义应包括针对不同设备方向和宽高比的布局设计。这样做的原因是方向切换期间的性能至关重要,因此确保在方向变化时加载两个方向所需的所有组件是一个好主意。

相反,如果您选择使用Loader加载在不同方向所需的额外QML,您应该进行彻底的测试,因为这会影响方向更改的性能。

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

如果一个页面中包含的组件需要在多种不同的形态定义中托管,那么视图的布局状态应该取决于页面的宽高比(其直接容器)。同样,一个组件的不同实例可能位于用户界面中的多个不同容器内,因此其布局状态应由其父容器的宽高比决定。结论是,布局状态应始终遵循直接容器的宽高比(而不是当前设备屏幕的“方向”)。

在每个布局状态中,您应该使用原生的QML布局定义来定义项目之间的关系。更多信息请参见下文。在状态之间的过渡期间(由顶层方向变化触发),在锚点布局的情况下,可以使用AnchorAnimation元素来控制过渡。在某些情况下,您还可以在项目的宽度上使用NumberAnimation。请记住避免在动画的每一帧中进行复杂的JavaScript计算。在大多数情况下,使用简单的锚点定义和锚点动画可以帮助解决这个问题。

还有一些额外的情况需要考虑:

  • 如果你有一个页面在横屏和竖屏时看起来完全不同,也就是说,所有的子项都不同,该怎么办?对于每个页面,有两个子组件,分别定义布局,并在每个状态下使其中一个子项的透明度为零。你可以通过简单地将NumberAnimation过渡应用于透明度来使用交叉淡入淡出动画。

  • 如果你有一个页面在纵向和横向之间共享30%或更多的相同布局内容,该怎么办?在这种情况下,考虑使用一个具有纵向和横向状态的组件,以及一组单独的子项,其不透明度(或位置)取决于方向状态。这将使你能够为在方向之间共享的项目使用布局动画,而其他项目则淡入/淡出,或在屏幕上动画显示/隐藏。

  • 如果你在手持设备上有两个页面需要同时显示在屏幕上,例如在较大尺寸的设备上,该怎么办?在这种情况下,请注意你的视图组件将不再占据整个屏幕。因此,重要的是要记住,在所有组件中(特别是列表委托项)应该依赖于包含组件的宽度,而不是屏幕宽度。在这种情况下,可能需要在Component.onCompleted()处理程序中设置宽度,以确保在设置值之前已经构建了列表项委托。

  • 如果两种方向占用太多内存,无法同时将它们都保存在内存中怎么办?如果无法同时保留视图的两个版本,请使用Loader,但要注意在布局切换期间的交叉淡入淡出动画性能。一个解决方案可以是拥有两个作为Page子项的“启动屏幕”项,然后在旋转期间在这些项之间进行交叉淡入淡出。然后,您可以使用Loader加载另一个子组件,该组件将实际模型数据加载到另一个子Item中,并在Loader完成后交叉淡入淡出到该Item。