简介:在Windows桌面开发中,高DPI缩放是导致界面模糊、控件错位的关键因素。系统切换显示缩放比或分辨率时,仅启动时读取屏幕参数无法满足运行期适配需求。Qt通过QScreen信号机制提供动态监测能力,将Windows底层WM_DPICHANGED等事件转化为跨平台的信号,开发者可据此实时获取逻辑DPI与可用几何区域。基于该原理,将监听逻辑封装为HighDpiHelper工具类,实现缩放比换算与信号分发,统一支持QWidget与QML两种界面体系,有效解决跨屏拖动、热插拔显示器等场景下的界面刷新问题。本文结合DpiChangedDemo剖析QScreen信号连接、DPI映射、QSS强制刷新及多屏误判等实践经验,为长期驻留的桌面工具、会议客户端及工业控制面板提供一套可复用的高DPI动态适配方案。
1. Windows Qt 分辨率与缩放比动态监测:为什么这件事不能只在启动时做一次
Windows 下做 Qt 开发,用户随手把系统缩放从 100% 切到 150%,你这边如果不做 Qt 自动监测分辨率与自动监测缩放比,界面就会在几秒内变糊、错位。大多数项目只在启动时读一次屏幕参数,运行中就不管了,等到用户把窗口从高分屏拖到低分屏,字体叠字、控件错位、图片发糊全冒出来。今天拆的这份 DpiChangedDemo 恰好补上这个缺口:通过 QScreen 的信号系统实时监听分辨率与缩放比变化,封装成 HighDpiHelper 供 QWidget 和 QML 两套界面复用,还能在变化发生时动态调整窗口布局和字体。适合正在做桌面工具、会议客户端、工业控制面板这类长期驻留程序的人,拿它当底子比从零实现省心得多。
2. 分辨率与缩放比的信号机制:QScreen 怎么把系统事件转成 Qt 信号
2.1 监听 QScreen 而非全局事件
在 Windows 层面,缩放和分辨率变化会让系统发出 WM_DISPLAYCHANGE、WM_DPICHANGED 这类消息,Qt 在事件循环里已经把它们翻译成 QScreen 层的信号。开发者直接在 QScreen 上 connect,就能获得跨平台一致的体验,不用自己碰 Win32 API。这个 demo 的核心价值就在这里:它把最容易被忽略的“运行期变化”通过现成的 Qt 信号暴露出来,开发者只需要关心槽函数里怎么写适配逻辑。
QScreen 上有几个高频信号,实际使用时它们的分工很明确:
| 信号 | 触发时机 | 适合做的事 |
|---|---|---|
| availableGeometryChanged | 可用工作区变化 | 窗口最大尺寸、边界修正 |
| logicalDotsPerInchChanged | 逻辑 DPI 改变 | 字体、控件缩放 |
| physicalDotsPerInchChanged | 物理 DPI 改变 | 高像素密度场景的特殊处理 |
| virtualGeometryChanged | 多屏虚拟桌面几何变化 | 窗口位置约束 |
连接时不能只处理一个 primaryScreen。最常见的问题是只监听主屏,副屏换缩放后完全没有反馈。正确做法是先遍历 QGuiApplication::screens(),对每一个 QScreen 都建立连接;同时监听 QGuiApplication 的 screenAdded / screenRemoved,热插拔显示器时能重新接信号。这个逻辑放在 HighDpiHelper 构造里做,能避免每个窗口各写一套重复的连线代码。
刚开始调试时,建议在 Helper 的槽函数里先打印一行日志,确认信号链路是通的:
qDebug() << "screen changed:" << screen->name() << "dpi:" << screen->logicalDotsPerInch() << "geo:" << screen->availableGeometry();如果这条日志在切换缩放时都没有出现,说明问题在连接本身,而不是后面的界面适配代码。我一般的排查顺序是:先看日志,再确认屏幕列表有没有变化,最后才动布局代码。信号连接还有另一个容易被忽略的细节:QScreen 指针生命周期可能比窗口短。拔掉显示器后,QScreen 对象会被销毁,如果槽函数里还在裸用这个指针,崩溃率非常高。工具类里用 QPointer 持有屏幕对象,或者每次从 QGuiApplication::screens() 重新取实例,都是可接受的方案。
2.2 logicalDotsPerInch 与缩放比的映射关系
Windows 上逻辑 DPI 和显示缩放档位呈线性映射:100% 缩放对应 96 DPI,125% 对应 120,150% 对应 144,200% 对应 192。Qt 暴露的 logicalDotsPerInch 恰好反映这个值,所以缩放比可以用简单的除法得到。
// HighDpiHelper.cpp 内部换算逻辑 qreal HighDpiHelper::currentScaleFactor(QScreen *screen) const { if (!screen) { return 1.0; } const qreal logicalDpi = screen->logicalDotsPerInch(); return logicalDpi / 96.0; }这里用 96 作为基准值,对应 Windows 默认 100% 缩放。96 是微软定义的基准 DPI,绝大多数 Windows 程序都按它展开,所以这个比例是安全的。需要留个心眼:logicalDotsPerInch 在部分 Linux 桌面环境下可能返回 96 或 72 不固定,如果项目以后要跨平台,最好把基准值做成可配置项,Windows 上就写 96。
拿到这个比例之后,界面所有基于“初始设计像素”的尺寸都应该乘以它。比如设计稿以 100% 缩放下的 14px 字体为基准,150% 缩放时就要用 21px。写死在 QSS 里的像素值不经过换算,表现就是字体不变大、控件间距失灵。还有一个边界情况:窗口跨屏拖动时可能一半在主屏一半在副屏,这时候取哪个屏幕的 DPI 取决于你的业务。常见做法是以窗口中心点所在屏幕为准,或者以鼠标所在屏幕为准,两种方案在 5.3 里会展开讲。
Qt 5 和 Qt 6 在高 DPI 默认行为上差异很大。Qt 6 默认开启 highdpi scaling,窗口坐标已经被 Qt 缩放;Qt 5 需要手动在 QApplication 构造之前打开开关:
// main.cpp 最前面 #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps); #endif QApplication app(argc, argv);这两个 attribute 前者让 Qt 按逻辑坐标缩放界面,后者让位图在高 DPI 下用物理像素渲染,避免图片发虚。迟调用没有用,必须放在 QApplication 实例化之前,否则 Qt 已经用系统默认 DPI 完成初始化,后面的设置全部失效。这是我反复踩过的坑,也是不少 Qt5 项目升级到高分屏后界面依然模糊的根源。
提示:如果程序里自己调用了 SetProcessDpiAwareness 这类 Windows API,可能会和 Qt 的 DPI 管理机制冲突,表现为拖动跨屏时缩放比例不更新。建议把 DPI 感知完全交给 Qt,不要重复设置。
2.3 为什么单独隔离出 HighDpiHelper
把监听代码直接写进 MainWindow 不是不行,但多窗口项目里每个窗口都复制一套 connect、换算、刷新逻辑,后期维护很痛苦。HighDpiHelper 将屏幕监听、比例换算、信号分发集中到一个类,界面层只管“收到通知后怎么调整自己”。项目正文里提供了这个头文件,说明作者也是按这个思路封装的。
Helper 需要暴露的属性尽量精简:一个只读的 scaleFactor,对应 Q_PROPERTY 供 QML 属性绑定使用,再加上两个对外信号。QWidget 侧可以 connect 信号,QML 侧可以直接绑定属性,两种界面框架共用同一套监听逻辑,这正是这个 demo 最值得复用的部分。
对外信号里如果带了 QScreen* 参数,QML 侧会直接忽略这个参数类型,因为 QML 无法解析裸的 C++ 指针。实际操作中我一般把 QML 用得到的信号单独设计一份,不带 QScreen 参数,只带 qreal scale 或 QRect;带 QScreen 的信号只给 QWidget 用,这样两边都干净。
3. QWidget 调用实现:把缩放变化接回窗口布局
3.1 工程文件与模块依赖
DpiChangeDemo 目录下包含 widget.h、widget.cpp、main.cpp、HighDpiHelper.h 和 DpiChangeDemo.pro,结构非常贴合日常演示工程。.pro 文件里的关键配置如下:
QT += core gui greaterThan(QT_MAJOR_VERSION, 4): QT += widgets CONFIG += c++11 TARGET = DpiChangeDemo SOURCES += main.cpp widget.cpp HEADERS += widget.h HighDpiHelper.h这里有几个关键点。首先是 widgets 模块:Qt 5 里用 QWidget 必须显式声明 widgets,否则链接时会出现一堆 QWidget 相关未定义错误;Qt 6 里还需要确认没有混用旧构建缓存,否则会出现类似 “cannot mix incompatible Qt library” 的报错。其次是 CONFIG += c++11,如果要在 connect 里写 lambda 表达式,C++11 是底线;新项目建议直接 c++17,编译器支持更好。
另外要注意 widget.cpp 的编码格式。MSVC 编译器对带中文的 QStringLiteral 字符串很敏感,文件没有保存为 UTF-8 时会出现乱码。如果遇到这个问题,在 .pro 里加一行:
msvc: QMAKE_CXXFLAGS += /utf-8加这行之后 Qt Creator 会要求重新运行 qmake,然后重新构建。这个细节和 DPI 无关,但很多人调试到一半发现中文标题乱码,会误以为是缩放适配的锅,白查半天。
3.2 在 Widget 中连接 Helper 信号
HighDpiHelper 的接入方式有两种:在 Widget 内创建成员,或者在 main.cpp 里创建后传入。demo 里直接从 HighDpiHelper.h 引入,在 Widget 构造时实例化即可。为了让界面能感知缩放变化,需要在构造函数里把 scaleFactorChanged 接到自己的刷新槽函数。
// widget.cpp 关键片段 Widget::Widget(QWidget *parent) : QWidget(parent) , m_helper(new HighDpiHelper(this)) { setWindowTitle(QStringLiteral("DPI Change Demo (QWidget)")); connect(m_helper, &HighDpiHelper::scaleFactorChanged, this, &Widget::onScaleFactorChanged); connect(m_helper, &HighDpiHelper::screenResolutionChanged, this, &Widget::onResolutionChanged); } void Widget::onScaleFactorChanged(qreal scale) { QFont font = this->font(); font.setPixelSize(qRound(14 * scale)); setFont(font); layout()->activate(); update(); }代码里做了三件事:调整字体像素大小、重新激活布局、触发重绘。font.setPixelSize 取 14 作为设计基准像素,qRound 做四舍五入避免出现 13.5px 这种半像素。setFont 之后布局不会立即重算,必须调用 layout()->activate(),否则窗口已经显示的情况下可能看到的还是旧尺寸。
字体缩放只是最基础的一项,间距、图标、控件最小高度都要按比例走。我的习惯是定义一个基准尺寸常量,所有控件尺寸都用它乘 scaleFactor,而不是在槽函数里逐个硬编码。另外要注意 setFont 和 setStyleSheet 的优先级问题:如果项目同时设置了全局 QSS,QSS 里的字体规则会覆盖 setFont 的结果。所以要么全部走 QSS,要么全部在代码里设置,两者混用会让缩放后字体忽大忽小。
3.3 QSS 样式表的强制刷新问题
如果项目使用 QSS 来定义外观,缩放变化时坑更多。QSS 里的 px 值是在样式解析阶段确定的,直接 setStyleSheet 新字符串后,控件不一定会重新解析。我在实际项目中遇到的情况是:字体变了,但按钮高度还是老样子,因为 QSS 的 padding 没有参与布局刷新。
处理方式是强制 QStyle 重新 polish:
void Widget::refreshStyle(qreal scale) { style()->unpolish(this); QString qss = QStringLiteral( "QPushButton { font-size: %1px; padding: %2px; }") .arg(qRound(14 * scale)) .arg(qRound(6 * scale)); setStyleSheet(qss); style()->polish(this); update(); }unpolish/polish 这对调用会通知 QStyle 丢弃旧样式缓存,然后用新 QSS 重建。顺序不能反,也不能漏掉 update()。这套写法对 QWidget 体系内的 QPushButton、QLabel、QLineEdit 都有效,但如果是自绘控件,比如重写了 paintEvent 的组件,就得在槽函数里更新成员变量并调用 update(),因为 QSS 路径不会经过自绘逻辑。
3.4 分辨率变化时窗口边界修正
分辨率变化不像缩放比那样需要逐控件调整,主要问题是窗口可能超出新的屏幕可用区域。槽函数里拿到新的 QRect 后,先求交集再设置几何:
void Widget::onResolutionChanged(QScreen *screen, const QRect &availableGeometry) { if (!screen) { return; } const QRect intersected = availableGeometry.intersected(this->geometry()); if (intersected != this->geometry()) { setGeometry(intersected); } }这里的核心逻辑是 intersect:分辨率缩小时,原窗口位置可能部分或全部掉出屏幕,求交集能把窗口拉回可见区域。availableGeometry 已经扣除了任务栏,所以窗口不会和任务栏重叠。如果不管这个细节,用户把分辨率调低后窗口一部分跑到屏幕外,鼠标很难把它拖回来,体验非常糟糕。实际项目里还要考虑多屏情况,窗口可能在副屏上,所以 onResolutionChanged 里最好再通过 QGuiApplication::screenAt 确认窗口当前到底在哪块屏。
4. QML 调用实现:属性绑定与 Connections 双保险
4.1 C++ Helper 注入 QML 上下文
QML 工程里 main.cpp 负责两件事:创建 HighDpiHelper 实例,把它注入到 QML 的上下文。这样 QML 里可以直接访问 dpiHelper.scaleFactor,也才能收到 C++ 侧转发来的信号。demo 里 HighDpiHelper.h、main.qml、qml.qrc 都在,走的就是这个路线。
// QML 版 main.cpp #include <QGuiApplication> #include <QQmlApplicationEngine> #include <QQmlContext> #include "HighDpiHelper.h" int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); HighDpiHelper helper; QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("dpiHelper", &helper); engine.load(QUrl(QStringLiteral("qrc:/main.qml"))); return app.exec(); }注意 helper 的创建顺序:它必须在 engine.load 之前完成,否则 QML 里引用的 dpiHelper 是空值,属性绑定会静默失败。另外 HighDpiHelper 的对象生命周期必须跨越 engine 生命周期,最简单的方式是把 helper 放在 main 函数栈上,或者用 QScopedPointer 管理。如果把 helper 声明在局部作用域提前释放,QML 侧一旦访问 dpiHelper 就会直接崩溃。
如果项目里开了多个 QQmlApplicationEngine,要注意 setContextProperty 是 per-engine 的,每个引擎都要单独注入一次。还有一种常见做法是用 qmlRegisterSingletonType 注册单例,那样所有引擎都能直接用,不需要注入,不过 demo 的工程结构没有看到 qmldir 或注册代码,setContextProperty 是更轻量的方案。
4.2 QML 中监听 scaleFactor 的两种写法
第一种写法用 Connections 显式接收 C++ 信号,适合需要在缩放变化时执行额外业务逻辑的场景。
import QtQuick import QtQuick.Window Window { id: root width: 400 * dpiHelper.scaleFactor height: 300 * dpiHelper.scaleFactor visible: true title: "DPI Change Demo (QML)" Text { id: infoText anchors.centerIn: parent font.pixelSize: 14 * dpiHelper.scaleFactor text: "Scale: " + dpiHelper.scaleFactor.toFixed(2) } Connections { target: dpiHelper function onScaleFactorChanged(scale) { root.width = 400 * scale root.height = 300 * scale infoText.font.pixelSize = 14 * scale } } }注意:QML 的 import 语句在 Qt 6 里可以不带版本号,Qt 5 则需要写成 import QtQuick 2.15 这种显式方式。如果编译报 module 相关的错,先查这里。
这里 window 的宽高和字体大小同时用了两种机制:一个是属性绑定,一个是 Connections 槽函数。两者并存没问题,属性绑定负责“值变化即更新”,Connections 负责“变化时还能多做点别的”。如果只需要界面随缩放自动调整,用纯属性绑定就够,可以完全不写 Connections。Qt 6 里 Connections 推荐用 function 写法,旧的 onScaleFactorChanged: { } 形式虽然还能跑,但新写法更清晰,不容易和属性绑定混淆。
第二种方式是把 scaleFactor 存到根对象的一个自定义属性里,比如 property real uiScale: dpiHelper.scaleFactor,所有子项都引用 uiScale。这样以后改基准比例时,所有引用点一次更新,逻辑更集中。两种写法各有适用场景,单一窗口用属性绑定,多窗口或多页面用 Connections 统一分发更可控。
4.3 QML 布局优先用锚点与 Layout 而不是绝对坐标
在 QML 响应缩放时,有一个和 QWidget 类似的原则:能用相对布局就不要写死绝对坐标。RowLayout、ColumnLayout、GridLayout 搭配 Layout.fillWidth 和 Layout.fillHeight,窗口尺寸变化时子项自动重排,不需要每个控件都乘一遍 scaleFactor。字体像素大小仍然要乘,但间距、对齐这些交给布局容器处理,代码量会少很多。
我见过不少 QML 项目用 x、y 坐标硬拼界面,缩放一变全部错位。后来改成 anchor 加 Layout 后,窗口宽高变化时的适配工作基本消失。属性和信号是双保险,布局容器是第三保险,三层配合才不会在奇怪的位置翻车。表格型表单是例外,那种行列对齐的复杂布局用 GridLayout 配合 columnSpan 更稳,纯锚点会写得很痛苦。
4.4 图片资源的 DPI 适配
QML 里 Image 的 sourceSize 也需要乘 scaleFactor,否则高清屏上图片会被放大后发虚:
Image { source: "qrc:/images/icon.png" sourceSize.width: 32 * dpiHelper.scaleFactor sourceSize.height: 32 * dpiHelper.scaleFactor smooth: true }sourceSize 设置的是源图片解码尺寸,而不是显示尺寸。不设置的话,高分屏下 Qt 会按逻辑尺寸拉伸位图,看起来模糊。设置 sourceSize 等于告诉它按物理像素解码,清晰度能保住。如果图片资源本身是矢量格式,比如 SVG,则不需要这个设置,矢量图天然适配任意缩放。
这里有个隐藏性能问题:sourceSize 一旦变化,Qt 会重新解码图片。如果用户频繁拖动缩放滑块,会导致 Image 反复解码,界面卡顿。解决办法是在 scaleFactorChanged 的槽函数里做一个简单的节流,用 QTimer 延迟 100ms 再更新 sourceSize,避免高频触发。
5. 高 DPI 动态监测避坑指南:五个真实翻车场景与修复
5.1 切换缩放后界面不更新
现象:系统缩放从 100% 切到 150%,程序界面没有任何变化,日志里信号没触发。
原因分三类:一是只监听了 QGuiApplication::primaryScreen(),切换到副屏的窗口收不到变化;二是 QScreen 指针在窗口初始化后重建,老指针已经失效;三是窗口被 setFixedSize 锁死了,即使信号触发,布局也无法重排。
解决:在 Helper 构造里遍历 QGuiApplication::screens() 逐屏连接,并监听 screenAdded / screenRemoved 做重连。窗口侧不要使用 setFixedSize,改用 setMinimumSize 加 setMaximumSize,给布局留出重新计算的空间。
5.2 Qt5 下高 DPI 属性设置无效
现象:Qt 5 项目在 150% 缩放的屏幕上打开,整套界面还是按 96 DPI 渲染,字体小得看不清,图片发虚。
原因:AA_EnableHighDpiScaling 必须在 QApplication 实例化之前设置,否则 Qt 已经使用系统检测到的 DPI 完成了初始化,之后再设置不会生效。
解决:在 main 函数的最前面,包含头文件之后立即写 setAttribute,用条件编译保证 Qt 6 不受影响。关键代码前面已经写过,这里强调一点:如果同一套代码在 Qt 5.12 和 5.15 下表现不同,优先检查这个开关有没有被某个后加载的插件重新覆盖。
5.3 多屏不同缩放档位,窗口拖动后仍然错位
现象:主屏 150%,副屏 125%,窗口从主屏拖到副屏后,字体没有随副屏缩放变化,位置也有偏移。
原因:拖动跨屏属于运行期变化,窗口所在屏幕变了,但程序仍然使用创建时的 screen 指针。
解决:在窗口移动或显示时,用 QGuiApplication::screenAt 重新获取当前屏幕,再取该屏幕的 DPI 做刷新:
void Widget::maybeRefreshForScreenChange() { QScreen *current = QGuiApplication::screenAt(this->frameGeometry().center()); if (current && current != m_lastScreen) { m_lastScreen = current; qreal scale = current->logicalDotsPerInch() / 96.0; onScaleFactorChanged(scale); } }注意 QGuiApplication::screenAt 接收的是全局坐标点,要用窗口的 frameGeometry().center(),如果用 pos() 取的是窗口左上角,跨屏窗口在边缘时容易误判。还有一个替代方案是重写 QWindow::screenChanged,但 QWidget 层更方便的还是 screenAt。
5.4 显示器热插拔后监听失效
现象:拔掉外接显示器再插回来,程序不再响应缩放变化,甚至出现悬空指针崩溃。
原因:QScreen 信号是在插拔瞬间建立的,热插拔时旧 QScreen 对象销毁,新 QScreen 对象产生,之前连接的信号全部失效。
解决:在 Helper 中监听 QGuiApplication 的 screenAdded 和 screenRemoved,重新绑定所有屏幕:
connect(qApp, &QGuiApplication::screenAdded, this, [this](QScreen *screen) { connectScreenSignals(screen); }); connect(qApp, &QGuiApplication::screenRemoved, this, [this](QScreen *screen) { screen->disconnect(this); });connectScreenSignals 是封装的函数,把遍历逻辑抽到那里,新屏幕加进来时调用一次即可。screenRemoved 后的 disconnect 不是必须的,但写上能避免悬空信号在个别平台上触发未定义行为。
5.5 修改缩放比后出现黑边或窗口闪烁
现象:缩放切换的瞬间,窗口出现短暂黑边或闪烁,然后恢复正常。
原因:这是 Windows 在 DPI 切换时对窗口重新布局的正常现象,但如果程序监听了 resizeEvent 并在里面做重型操作,闪烁感会被放大。
解决:在 resizeEvent 中尽量只做轻量操作,比如记录新尺寸、更新布局标志位,不要在这里创建对象、读数据库、重新加载图片资源。把重量级刷新放到 QTimer::singleShot(0, ...) 里,等布局稳定后再执行。如果闪烁频繁,可以临时给窗口设置 Qt::WA_OpaquePaintEvent,减少背景擦除的重绘次数,但要注意这是双刃剑,复杂绘制下可能更慢。
6. 缩放切换回归:把验证变成十分钟内可执行的清单
给项目加一个调试开关,是我每次做 DPI 适配时最实用的手段。在 main 函数里读一个环境变量,如果存在就强制覆盖初始 scaleFactor:
// main.cpp 调试用途 bool ok = false; const qreal debugScale = qEnvironmentVariable("DPI_DEBUG_SCALE").toDouble(&ok); if (ok && debugScale > 0.0) { helper.setDebugScale(debugScale); }这样测试时先设一次 DPI_DEBUG_SCALE=1.5 启动程序,确认界面布局正常,再切回系统真实缩放,对照两次渲染结果。能快速区分“是监听没触发”还是“布局逻辑写错了”。这个技巧同时适用于 QWidget 和 QML 两条测试路径,共用一份调试代码。
回归验证的清单建议固定成一张表,每次都按表执行:
| 变更类型 | 触发方式 | 期望结果 |
|---|---|---|
| 分辨率变化 | 显示设置中切换 1920×1080 到 1366×768 | 窗口回到可用区域,无控件被挤出屏幕 |
| 缩放比变化 | 100% 切到 150% 再切到 125% | 字体、间距、图片同步变化,无模糊 |
| 跨屏拖动 | 窗口从 150% 主屏拖到 125% 副屏 | 窗口内容按新屏幕 DPI 即时刷新 |
| 热插拔显示器 | 运行时拔掉外接屏再插回 | 信号重新连接,界面不崩溃 |
每个变更类型跑一遍,正常就录屏存档,异常就抓日志里的 scaleFactor 输出和屏幕几何信息。这套流程一次十分钟左右,但能挡住大多数发布后才会暴露的 DPI 问题。
使用这个 demo 时还有个小习惯:日志打印要放在 HighDpiHelper 的信号发出处,而不是槽函数里。因为信号可能被多个界面同时接收,在源头打印一次既能确认系统事件确实到达 Qt 层,又能排除界面层代码的问题。从那以后我每次提交 DPI 相关改动,都强制走一遍上面这张表,然后再去看视觉还原度,希望帮到你。
本文还有配套的精品资源,点击获取