Skip to content

移动端专项优化

Warning

The current page still doesn't have a translation for this language.

You can read it through google translate.

移动端(Android / iOS / 鸿蒙)与桌面端有两个本质差异,SOUI 针对它们做了专门优化:

  1. 没有自己的消息循环(msgloop)。移动端进程由 JVM / ArkTS 运行时拉起,主循环在系统侧,业务代码无法像桌面 SHostDialog::DoModal 那样"再拉起一套嵌套消息循环"来阻塞等待对话框返回。为此 SOUI 提供了 SModalView(模态视图) 机制,在既有消息循环内实现模态效果。
  2. 交互是手指滑动,不是鼠标点击。为此 SPanel 及其子类的滚动条增加了 fling(惯性滑动) 支持,并相应调整了子控件对 WM_LBUTTONDOWN / WM_LBUTTONUP 的响应方式。

桌面端可以照常使用 SHostDialog,但移动端工程请改用下面的 SModalView;fling 则对所有平台生效(桌面端用鼠标拖拽同样会触发),只是对手指滑动的体验提升最明显。


一、SModalView:替代 SHostDialog 的模态视图

为什么不直接用 SHostDialog

桌面端 SHostDialog::DoModal 的退出依赖它自己管理一个消息循环:

// SOUI/include/core/SHostDialog.h
STDMETHOD_(IMessageLoop *, GetMsgLoop)(THIS) OVERRIDE;          // 对话框自己的消息循环
STDMETHOD_(INT_PTR, DoModal)(THIS_ HWND hParent, ...) OVERRIDE;  // 内部 RunModalLoop 阻塞
SAutoRefPtr<IMessageLoop> m_MsgLoop;                            // 嵌套消息循环

在 Android / 鸿蒙上,进程由 Java / ArkTS 运行时创建,消息泵(见 移动端概述)运行在系统框架内部,业务侧无法再独立 RunModalLoop。一旦在移动端调用 DoModal,要么卡死、要么收不到事件。

SModalView 不依赖任何嵌套消息循环:它把"模态窗口"挂到既有的 SRootWindow 窗口树里,靠普通命中测试天然地把输入优先路由到最顶层模态层,因此能在移动端正常工作,同时桌面端也能用。

原理

涉及三个对象(见 SOUI/include/core/SModalViewSession.h):

  • SModalRoot:全屏遮罩窗口,铺满 SRootWindow 客户区。它叠在非模态窗口之上,所有鼠标/键盘命中测试优先命中它,从而实现"模态"拦截。点它的空白背景(即没有命中任何子窗口)会以 IDCANCEL 退出当前模态会话(quitOnClick 属性控制)。
  • SModalView:真正可见的"对话框"窗口,作为 SModalRoot 的子窗口存在。支持 enterAnimation / exitAnimation,语义与 SRootWindow 的动画一致(用 anim: 前缀引用 XML 里定义的动画)。退出动画播完才真正销毁,所以能看到淡出/缩放效果。
  • 模态栈SHostWnd 维护一个模态会话栈。会话可嵌套(弹窗上再弹窗),但必须按相同顺序结束。每个 SModalRoot 有一个唯一的 ModalViewSessionID
flowchart TB
    RW[SRootWindow 窗口树]
    RW --> N[非模态业务窗口]
    RW --> MR1[SModalRoot #1 全屏遮罩]
    MR1 --> MV1[SModalView 可见对话框]
    MR1 -. 命中测试优先 .-> MR1
    MV1 --> B1[按钮等子控件]

    subgraph 嵌套示例
    MR1 --> MR2[SModalRoot #2]
    MR2 --> MV2[SModalView]
    end

API

SHostWnd 提供两组入口:

// SOUI/include/core/SHostWnd.h
// 已有一个 SModalRoot 对象时
ModalViewSessionID BeginModalViewSession(SModalRoot* pView, SWindow* pRoot = NULL);
// 直接给布局资源名,内部自动创建 SModalRoot 并塞进一个 modalview
SModalRoot* BeginModalViewSession(LPCTSTR pszLayout, SWindow* pRoot = NULL);

BOOL EndModalViewSession(ModalViewSessionID sessionID = 0, int exitCode = 0);
ModalViewSessionID GetLastModalViewSessionID() const;
  • BeginModalViewSession 返回唯一 ModalViewSessionID;传入的窗口不能已经挂到别的父窗口上。如果传的不是 SModalRoot,会被自动 reparent 进新建的 SModalRoot
  • 会话结束通过订阅 SModalRoot 上的 EventExitModalView 事件来感知,事件携带 exitCode
// SOUI/include/event/SEvents.h
DEF_EVT(EventExitModalView, EVT_EXIT_MODALVIEW, on_exit_modal_view,{
    int exitCode;
})

用法示例

C++ 侧(取自 demos/android-demo,与桌面 DoModal 的最大区别:Begin/End 不阻塞,结果通过事件回调回来):

// demos/android-demo/app/src/main/cpp/MainDlg.cpp
void CMainDlg::OnBtnModalInput() {
    // 1) 创建 SModalRoot 并加载布局
    SModalRoot *pModal = (SModalRoot*)SApplication::getSingleton()
                            .CreateWindowByName(SModalRoot::GetClassName());
    pModal->InitFromResId(_T("layout:model_view"));

    // 2) 进入模态会话(非阻塞,立即返回 session id)
    ModalViewSessionID session_id = BeginModalViewSession(pModal);

    // 3) 业务按钮以 EndModalViewSession 结束会话,并带回退出码
    pModal->FindChildByName(_T("btn_ok"))->SubscribeEvent(EventCmd::EventID, [=](IEvtArgs *e){
        EndModalViewSession(session_id, IDOK);
        return TRUE;
    });
    pModal->FindChildByName(_T("btn_cancel"))->SubscribeEvent(EventCmd::EventID, [=](IEvtArgs *e){
        EndModalViewSession(session_id, IDCANCEL);
        return TRUE;
    });

    // 4) 监听会话结束(动画播完后触发)
    pModal->SubscribeEvent(EventExitModalView::EventID, [=](IEvtArgs *e){
        EventExitModalView *e2 = sobj_cast<EventExitModalView>(e);
        SLOGI() << "modal return:" << e2->exitCode;
        return TRUE;
    });
}

XML 布局(demos/android-demo/.../uires/xml/model_view.xml):

<modalroot layout="anchor" colorBkgnd="rgba(0,0,0,0.5)">
    <modalview size="-2,-1" pos="0,0,6" offset="0,-1" layout="vbox" interval="10"
               enterAnimation="anim:modal_in" exitAnimation="anim:modal_out">
        <button size="-2,80" name="btn_ok"    text="OK"     skin="btn_primary"/>
        <button size="-2,80" name="btn_cancel" text="Cancel"/>
    </modalview>
</modalroot>
  • modalrootcolorBkgnd 即半透明背景遮罩;quitOnClick="1" 时点击遮罩区即 IDCANCEL 退出。
  • modalviewenterAnimation / exitAnimation 引用 uires/xml 中定义的 SOUI 动画资源。

和 SHostDialog 的对照

维度 SHostDialog::DoModal SModalView
消息循环 自己 RunModalLoop(嵌套) 复用应用既有循环
调用方式 阻塞直到返回 INT_PTR Begin 不阻塞,结果走 EventExitModalView
移动端可用 ❌ 拉不起自己的 msgloop
动画 无内置进出场动画 enter/exitAnimation

二、滚动条 fling(惯性滑动)

SPanel 以及它的一切子类(SListCtrlSListboxSTreeCtrl、滚动容器等)的滚动条都增加了 fling 支持:手指(或鼠标)快速滑动后松手,滚动不会立刻停,而是按松手瞬间的速度再做一段惯性减速滑动,符合移动端触摸习惯。

原理

实现位于 SOUI/src/core/SPanel.cpp

  1. 拖拽滚动过程中记录最近一次移动的速度 m_fLastVelocityX / m_fLastVelocityY(按时间差估算)。
  2. 松手(WM_LBUTTONUP)时,若距上次移动 < 100ms 且速度有效,调用 StartFlingAnimation(fReleaseVX, fReleaseVY)
  3. StartFlingAnimationSFloatAnimator(接入 GetTimelineHandlersMgr() 时间轴)从当前滚动位置动画到"按速度推算的目标位置",并在动画回调里 ScrollToPos + OnFlingScroll
  4. fling 动画运行期间会消费左键相关消息以中止本次惯性:触摸 WM_LBUTTONDOWN 或按住拖拽会立即 StopFlingAnimation,从而让"手指一碰就停"。
sequenceDiagram
    participant U as 用户手指
    participant P as SPanel
    participant A as SFloatAnimator
    U->>P: 拖拽(LBUTTONDOWN/MOVE) 记录速度
    U->>P: 松手(LBUTTONUP)
    P->>P: StartFlingAnimation(速度)
    P->>A: start()
    loop 动画帧
        A->>P: onAnimationUpdate → ScrollToPos
    end
    U->>P: 再次按下(LBUTTONDOWN)
    P->>A: StopFlingAnimation(立即停)

涉及的关键成员/虚函数(SOUI/include/core/SPanel.h):

void StartFlingAnimation(float fVelocityX, float fVelocityY); // 启动惯性
void StopFlingAnimation();                                    // 停止
virtual void OnFlingScroll();                                 // fling 每帧回调(可重写做滚动条高亮等)
SAutoRefPtr<SFloatAnimator> m_pFlingAnimatorV;                // 垂直惯性动画器
SAutoRefPtr<SFloatAnimator> m_pFlingAnimatorH;                // 水平惯性动画器

带来的子控件消息处理变化(重要)

加入拖拽滚动 / fling 后,SPanelWM_LBUTTONDOWN / WM_LBUTTONUP 消息处理优先服务于滚动(命中拖拽阈值就进入滚动、fling 进行中也要消费这些消息)。只有当一次按键不是滚动操作时,才会把"点击"转发给子控件。

源码逻辑(SOUI/src/core/SPanel.cpp:1346):

void SPanel::OnLButtonDown(UINT nFlags, CPoint pt) {
    LRESULT lRet = 0;
    if (HandleMouseDrag(WM_LBUTTONDOWN, nFlags, MAKELPARAM(pt.x, pt.y), lRet))
        return;                       // 被拖拽/fling 拦截,不再下传
    OnLButtonDownEx(nFlags, pt);      // 非滚动:转给 Ex 虚函数
    if (IsEnableDragMode()) StartDragPending(pt);
}

因此:

原来子控件直接重写 OnLButtonDown / OnLButtonUp 来响应点击的做法,在 SPanel 体系下会被滚动逻辑"吃掉"。要正确响应点击,应当改为重写 OnLButtonDownEx / OnLButtonUpEx 两个虚函数(同系列还有 OnMouseMoveEx)。

  • OnLButtonDownEx / OnLButtonUpEx / OnMouseMoveExSPanel 上新增的 virtual 钩子,默认实现只是转发到基类(__baseCls::OnLButtonDown 等)。
  • 你的控件继承 SPanel 或其子类时,用 Ex 系列取代原本的 OnLButtonDown / OnLButtonUp 重写,即可在"非滚动"的那次按下/抬起里安全地做自己的点击逻辑;滚动手势仍由 SPanel 统一处理。

SOUI 内置控件已据此迁移:SListCtrlSListboxSTreeCtrl 的点击处理都已改到 OnLButtonDownEx / OnLButtonUpEx(见它们的头文件及 SPanel.cpp 中的调用)。

迁移清单

如果你自己的控件类之前在 SPanel 派生体系里重写了:

  • OnLButtonDown → 改为重写 OnLButtonDownEx
  • OnLButtonUp → 改为重写 OnLButtonUpEx
  • (可选)OnMouseMove → 改为重写 OnMouseMoveEx

否则在可滚动容器里,点击事件会被拖拽/fling 逻辑拦截而收不到。


小结

优化 解决什么问题 桌面端 移动端
SModalView 移动端拉不起独立 msgloop,无法用 SHostDialog::DoModal 可用(但推荐统一用 SModalView ✅ 必须使用
滚动条 fling 手指滑动惯性、滚动手感 鼠标拖拽同样生效 ✅ 体验最明显
子控件 On*Ex 钩子 让点击逻辑不被滚动拦截 需同步迁移 需同步迁移