当前位置: 首页 > news >正文

【Android】浅析View.post()

【Android】浅析View.post()原理

本文参考:

Android 之你真的了解 View.post() 原理吗? - 简书

我们知道在onResume()方法中是无法准确获取到View的宽高的,这主要是因为View绘制流程开始的实际在onResume()方法之后。无法确认在onResume()方法中View的宽高为最终值。

一般情况下,我们使用View.post()解决问题。

View.post()为什么能获取到view的实际宽高?

View.post()

View 的 post 方法如下:

public boolean post(Runnable action) {// 首先判断AttachInfo是否为nullfinal AttachInfo attachInfo = mAttachInfo;if (attachInfo != null) {// 如果不为null,直接调用其内部Handler的postreturn attachInfo.mHandler.post(action);}// 否则加入当前View的等待队列getRunQueue().post(action);return true;
}

若 View 已附着到窗口(attachInfo != null),直接通过窗口线程的 Handler 执行任务,确保任务在 UI 线程执行。

AttachInfo 存在:直接通过窗口线程的 Handler 提交任务(UI 线程)。

AttachInfo 不存在:将任务存入本地队列 HandlerActionQueue,等待 View 附着后执行。

注意 AttachInfo 是 View 的静态内部类,每个 View 都会持有一个 AttachInfo,它默认为 null。

AttachInfo的几个关键属性:

WindowManager.LayoutParams:包含 View 的布局参数(如宽高、位置、对齐方式等)。

ViewRootImpl:指向当前 View 树的根节点对应的 ViewRootImpl 对象(负责协调 View 树的测量、布局和绘制)。

Context:提供应用上下文,用于获取资源(如字符串、Drawable 等)。

Display:当前窗口对应的屏幕显示信息(如尺寸、密度等)。

先来看下 getRunQueue ().post ():

private HandlerActionQueue getRunQueue() {if (mRunQueue == null) {mRunQueue = new HandlerActionQueue();}return mRunQueue;
}

getRunQueue () 返回的是 HandlerActionQueue,也就是调用了 HandlerActionQueue 的 post 方法:

public void post(Runnable action) {// 调用到postDelayed方法,这有点类似于Handler发送消息postDelayed(action, 0);
}// 实际调用postDelayed
public void postDelayed(Runnable action, long delayMillis) {// HandlerAction表示要执行的任务final HandlerAction handlerAction = new HandlerAction(action, delayMillis);synchronized (this) {if (mActions == null) {// 创建一个保存HandlerAction的数组mActions = new HandlerAction[4];}// 表示要执行的任务HandlerAction 保存在 mActions 数组中mActions = GrowingArrayUtils.append(mActions, mCount, handlerAction);// mActions数组下标位置累加1mCount++;}
}

HandlerAction 表示一个待执行的任务,内部持有要执行的 Runnable 和延迟时间;类声明如下:

private static class HandlerAction {// post的任务final Runnable action;// 延迟时间final long delay;public HandlerAction(Runnable action, long delay) {this.action = action;this.delay = delay;}// 比较是否是同一个任务// 用于匹配某个 Runnable 和对应的HandlerActionpublic boolean matches(Runnable otherAction) {return otherAction == null && action == null|| action != null && action.equals(otherAction);}
}

注意 postDelayed () 创建一个默认长度为 4 的 HandlerAction 数组,用于保存 post () 添加的任务;跟踪到这,大家是否有这样的疑惑:View.post () 添加的任务没有被执行?

实际上,此时我们要回过头来,重新看下 AttachInfo 的创建过程,先看下它的构造方法:

AttachInfo(IWindowSession session, IWindow window, Display display,ViewRootImpl viewRootImpl, Handler handler, Callbacks effectPlayer,Context context) {mSession = session;mWindow = window;mWindowToken = window.asBinder();mDisplay = display;// 持有当前ViewRootImplmViewRootImpl = viewRootImpl;// 当前渲染线程HandlermHandler = handler;mRootCallbacks = effectPlayer;// 为其创建一个ViewTreeObservermTreeObserver = new ViewTreeObserver(context);}

注意 AttachInfo 中持有当前线程的 Handler。翻阅 View 源码,发现仅有两处对 mAttachInfo 赋值操作,一处是为其赋值,另一处是将其置为 null。

mAttachInfo 赋值过程:

void dispatchAttachedToWindow(AttachInfo info, int visibility) {// 给当前View赋值AttachInfo,此时所有的View共用同一个AttachInfo(同一个ViewRootImpl内)mAttachInfo = info;// View浮层,是在Android 4.3添加的if (mOverlay != null) {// 任何一个View都有一个ViewOverlay// ViewGroup的是ViewGroupOverlay// 它区别于直接在类似RelativeLaout/FrameLayout添加View,通过ViewOverlay添加的元素没有任何事件// 此时主要分发给这些View浮层mOverlay.getOverlayView().dispatchAttachedToWindow(info, visibility);}mWindowAttachCount++;// ... 省略if ((mPrivateFlags & PFLAG_SCROLL_CONTAINER) != 0) {mAttachInfo.mScrollContainers.add(this);mPrivateFlags |= PFLAG_SCROLL_CONTAINER_ADDED;}//  mRunQueue,就是在前面的 getRunQueue().post()// 实际类型是 HandlerActionQueue,内部保存了当前View.post的任务if (mRunQueue != null) {// 执行使用View.post的任务// 注意这里是post到渲染线程的Handler中mRunQueue.executeActions(info.mHandler);// 保存延迟任务的队列被置为null,因为此时所有的View共用AttachInfomRunQueue = null;}performCollectViewAttributes(mAttachInfo, visibility);// 回调View的onAttachedToWindow方法// 在Activity的onResume方法中调用,但是在View绘制流程之前onAttachedToWindow();ListenerInfo li = mListenerInfo;final CopyOnWriteArrayList<OnAttachStateChangeListener> listeners =li != null ? li.mOnAttachStateChangeListeners : null;if (listeners != null && listeners.size() > 0) {// 通知所有监听View已经onAttachToWindow的客户端,即view.addOnAttachStateChangeListener();// 但此时View还没有开始绘制,不能正确获取测量大小或View实际大小listener.onViewAttachedToWindow(this);}// ...  省略// 回调View的onVisibilityChanged// 注意这时候View绘制流程还未真正开始onVisibilityChanged(this, visibility);// ... 省略
}

方法最开始为当前 View 赋值 AttachInfo。注意 mRunQueue 就是保存了 View.post () 任务的 HandlerActionQueue;此时调用它的 executeActions 方法如下:

public void executeActions(Handler handler) {synchronized (this) {// 任务队列final HandlerAction[] actions = mActions;// 遍历所有任务for (int i = 0, count = mCount; i < count; i++) {final HandlerAction handlerAction = actions[i];//发送到Handler中,等待执行handler.postDelayed(handlerAction.action, handlerAction.delay);}//此时不在需要,后续的post,将被添加到AttachInfo中mActions = null;mCount = 0;}
}

遍历所有已保存的任务,发送到 Handler 中排队执行;将保存任务的 mActions 置为 null,因为后续 View.post () 直接添加到 AttachInfo 内部的 Handler 。所以不得不去跟踪 dispatchAttachedToWindow () 的调用时机。

ViewRootImpl

每个 Activity 对应一个 Window,而 Window 的根视图是 DecorView。因此,一个 Activity 内的所有 View 共享同一个 AttachInfo

在 View 绘制流程启动时,ViewRootImpl 通过 host.dispatchAttachedToWindow(mAttachInfo, 0) 将 AttachInfo 传递给 DecorView:

mAttachInfo = new View.AttachInfo(mWindowSession, mWindow, display, this, mHandler, this,context);

一般 Activity 包含多个 View 形成 View Hierachy 的树形结构,只有最顶层的 DecorView 才是对 WindowManagerService “可见的”。

dispatchAttachedToWindow () 的调用时机是在 View 绘制流程的开始阶段。在 ViewRootImpl 的 performTraversals 方法,在该方法将会依次完成 View 绘制流程的三大阶段:测量、布局和绘制,不过这部分不是今天要分析的重点。

// View 绘制流程开始在 ViewRootImpl
private void performTraversals() {// mView是DecorViewfinal View host = mView;if (mFirst) {.....// host为DecorView// 调用DecorVIew 的 dispatchAttachedToWindow,并且把 mAttachInfo 给子viewhost.dispatchAttachedToWindow(mAttachInfo, 0);mAttachInfo.mTreeObserver.dispatchOnWindowAttachedChange(true);dispatchApplyInsets(host);.....} mFirst=false...// Execute enqueued actions on every traversal in case a detached view   enqueued an actiongetRunQueue().executeActions(mAttachInfo.mHandler);// View 绘制流程的测量阶段performMeasure();// View 绘制流程的布局阶段performLayout();// View 绘制流程的绘制阶段performDraw();...}

host 的实际类型是 DecorView,DecorView 继承自 FrameLayout。

每个 Activity 都有一个关联的 Window 对象,用来描述应用程序窗口,每个窗口内部又包含一个 DecorView 对象,DecorView 对象用来描述窗口的视图 — xml 布局。通过 setContentView () 设置的 View 布局最终添加到 DecorView 的 content 容器中。

跟踪 DecorView 的 dispatchAttachedToWindow 方法的执行过程,DecorView 并没有重写该方法,而是在其父类 ViewGroup 中:

void dispatchAttachedToWindow(AttachInfo info, int visibility) {mGroupFlags |= FLAG_PREVENT_DISPATCH_ATTACHED_TO_WINDOW;super.dispatchAttachedToWindow(info, visibility);mGroupFlags &= ~FLAG_PREVENT_DISPATCH_ATTACHED_TO_WINDOW;// 子View的数量final int count = mChildrenCount;final View[] children = mChildren;// 遍历所有子Viewfor (int i = 0; i < count; i++) {final View child = children[i];// 遍历调用所有子View的dispatchAttachedToWindow// 为每个子View关联AttachInfochild.dispatchAttachedToWindow(info,combineVisibility(visibility, child.getVisibility()));}// ...
}

for 循环遍历当前 ViewGroup 的所有 childView,为其关联 AttachInfo。子 View 的 dispatchAttachedToWindow 方法在前面我们已经分析过了:首先为当前 View 关联 AttachInfo,然后将之前 View.post () 保存的任务添加到 AttachInfo 内部的 Handler 。

当 View 首次被添加到窗口时,dispatchAttachedToWindow() 会在 performTraversals() 中被调用,此时会发生两件事:

  1. AttachInfo 被传递给所有子 View
  2. 本地队列中的任务被迁移到窗口 Handler

关键代码如下:

// ViewRootImpl.performTraversals()
private void performTraversals() {if (mFirst) {host.dispatchAttachedToWindow(mAttachInfo, 0); // 传递 AttachInfo 并执行本地队列}// 执行完 dispatchAttachedToWindow 后,再进行测量、布局、绘制performMeasure();performLayout();performDraw();// 最后执行 ViewRootImpl 自身队列中的任务(与 View.post() 无关)getRunQueue().executeActions(mAttachInfo.mHandler);
}

这里的关键在于:dispatchAttachedToWindow() 在测量、布局、绘制之前执行,但任务迁移到 Handler 后,需要等待当前消息处理完成才能执行。

因此,View.post () 的任务实际会在当前绘制周期结束后执行,此时 View 已经完成了布局和绘制,可以安全获取宽高。

那么我们就可以回答开头的问题:

为什么 View.post () 能可靠获取 View 尺寸?

  1. View 首次布局前:调用 view.post(),任务存入本地队列
  2. 绘制流程启动dispatchAttachedToWindow() 被调用,AttachInfo 传递给所有 View
  3. 本地队列任务迁移:任务被发送到窗口 Handler 的消息队列尾部
  4. 当前绘制周期完成:测量、布局、绘制全部结束
  5. 执行 View.post () 的任务:此时 View 尺寸已经确定

碎片化问题

当你创建一个独立的 View 并调用 post () 时:

final ImageView view = new ImageView(this);view.post(new Runnable() {@Overridepublic void run() {// do something}});

此时 View 的 mAttachInfo 为 null,任务会被存入 HandlerActionQueue。但由于该 View 未被添加到窗口,dispatchAttachedToWindow() 永远不会被调用,导致:

  1. AttachInfo 未被传递给该 View
  2. 本地队列中的任务永远不会被迁移到 UI 线程执行

这就是独立 View 的 post () 任务无法执行的根本原因。

不过可以将View添加到窗口,从而主动触发View绘制流程。当你调用 contentView.addView(view) 时,系统会:

  1. 将新 View 添加到 ViewGroup 中
  2. 标记该 ViewGroup 需要重新布局
  3. 在下一帧绘制时触发整个 View 树的重绘

例如:

// 将View添加到窗口
// 此时重新发起绘制流程,post任务会被执行
contentView.addView(view);

关键代码路径如下:

// ViewGroup.addView()
public void addView(View child, int index) {// ...requestLayout(); // 标记需要重新布局invalidate(true); // 标记需要重绘
}// ViewRootImpl.performTraversals()
private void performTraversals() {if (mFirst || ...) { // 首次绘制或需要重新布局host.dispatchAttachedToWindow(mAttachInfo, 0); // 传递 AttachInfo}// 执行测量、布局、绘制
}

AttachInfo是什么

AttachInfo是一个包含了大量关于View如何与窗口关联以及如何绘制自身的数据结构。当一个View被附加(attached)到Window上时,系统会为这个View创建并填充一个AttachInfo对象。同一个window下的所有View,持有的AttachInfo都是同一份。

创建View#AttachInfoViewRootImpl生成,mAttachInfo实例维护在ViewRootImpl中。在ViewRootImpl的构造函数中会创建AttachInfo对象,示例代码如下:

ViewRootImpl(Context context, Display display, IWindowSession session, boolean useSfChoreographer) {mAttachInfo = new View.AttachInfo(mWindowSession, mWindow, display, this, mHandler, this, context);
}
  • Activity启动时渲染布局:调用流程为ViewRootImpl#doTraversal() -> ViewRootImpl#performTraversals() -> Decorview#dispatchAttachedToWindow(AttachInfo info, int visibility) -> ViewGroup#dispatchAttachedToWindow(AttachInfo info, int visibility) -> View#dispatchAttachedToWindow(AttachInfo info, int visibility),将AttachInfo关联到对应的View。
  • 动态添加View:例如点击button时给root添加子View,调用栈为View.OnClickListener#onClick(View v) -> ViewGroup#addView(View child, LayoutParams params) -> ViewGroup#addView(View child, int index, LayoutParams params) -> ViewGroup#addViewInner(View child, int index, LayoutParams params, boolean preventRequestLayout) -> View#dispatchAttachedToWindow(AttachInfo info, int visibility)。最终保证了整个View树中的View#AttachInfo是同一个对象。

mAttachInfo 置 null 的过程

当 View 从窗口中移除时,需要释放 AttachInfo 以避免内存泄漏。这一过程由 dispatchDetachedFromWindow() 方法触发,实际是调用其父类 ViewGroup :

// ViewGroup.dispatchDetachedFromWindow()
void dispatchDetachedFromWindow() {// 递归释放所有子 View 的 AttachInfofinal int count = mChildrenCount;final View[] children = mChildren;for (int i = 0; i < count; i++) {children[i].dispatchDetachedFromWindow(); // 关键递归点}// 父类 View 释放逻辑super.dispatchDetachedFromWindow();
}// View.dispatchDetachedFromWindow()
void dispatchDetachedFromWindow() {// 回调用户逻辑onDetachedFromWindow();// 通知监听器ListenerInfo li = mListenerInfo;if (li != null && li.mOnAttachStateChangeListeners != null) {for (OnAttachStateChangeListener listener : li.mOnAttachStateChangeListeners) {listener.onViewDetachedFromWindow(this); // 触发监听器回调}}// 释放核心引用mAttachInfo = null; // 置空 AttachInfomOverlay = null;    // 释放浮层引用
}

递归释放:ViewGroup 会先释放所有子 View 的 AttachInfo,确保资源释放顺序为 子→父

回调顺序:先执行 onDetachedFromWindow()(用户自定义逻辑),再通知监听器,最后置空引用。

void dispatchDetachedFromWindow() {AttachInfo info = mAttachInfo;if (info != null) {int vis = info.mWindowVisibility;if (vis != GONE) {// 通知 Window显示状态发生变化onWindowVisibilityChanged(GONE);if (isShown()) {onVisibilityAggregated(false);}}}// 回调View的onDetachedFromWindowonDetachedFromWindow();onDetachedFromWindowInternal();// ... 省略ListenerInfo li = mListenerInfo;final CopyOnWriteArrayList<OnAttachStateChangeListener> listeners =li != null ? li.mOnAttachStateChangeListeners : null;if (listeners != null && listeners.size() > 0) {// 通知所有监听View已经onAttachToWindow的客户端,即view.addOnAttachStateChangeListener();for (OnAttachStateChangeListener listener : listeners) {// 通知回调 onViewDetachedFromWindowlistener.onViewDetachedFromWindow(this);}}// ... 省略// 将AttachInfo置为nullmAttachInfo = null;if (mOverlay != null) {// 通知浮层ViewmOverlay.getOverlayView().dispatchDetachedFromWindow();}notifyEnterOrExitForAutoFillIfNeeded(false);
}

可以看到在 dispatchDetachedFromWindow 方法,首先回调 View 的 onDetachedFromWindow (),然后通知所有监听者 onViewDetachedFromWindow (),最后将 mAttachInfo 置为 null。

由于 dispatchAttachedToWindow 方法是在 ViewRootImpl 中完成,此时很容易想到它的释放过程肯定也在 ViewRootImpl,跟踪发现如下调用过程:

void doDie() {// 检查执行线程checkThread();synchronized (this) {if (mRemoved) {return;}mRemoved = true;if (mAdded) {// 回调View的dispatchDetachedFromWindowdispatchDetachedFromWindow();}if (mAdded && !mFirst) {destroyHardwareRenderer();// mView是DecorViewif (mView != null) {int viewVisibility = mView.getVisibility();// 窗口状态是否发生变化boolean viewVisibilityChanged = mViewVisibility != viewVisibility;if (mWindowAttributesChanged || viewVisibilityChanged) {try {if ((relayoutWindow(mWindowAttributes, viewVisibility, false)& WindowManagerGlobal.RELAYOUT_RES_FIRST_TIME) != 0) {mWindowSession.finishDrawing(mWindow);}} catch (RemoteException e) {}}// 释放画布mSurface.release();}}mAdded = false;}// 将其从WindowManagerGlobal中移除// 移除DecorView// 移除DecorView对应的ViewRootImpl// 移除DecorViewWindowManagerGlobal.getInstance().doRemoveView(this);
}

可以看到 dispatchDetachedFromWindow 方法被调用,注意方法最后将 ViewRootImpl 从 WindowManager 中移除。

经过前面的分析我们已经知道 AttachInfo 的赋值操作是在 View 绘制任务的开始阶段,而它的调用者是 ActivityThread 的 handleResumeActivity 方法,即 Activity 生命周期 onResume 方法之后。

那它是在 Activity 的哪个生命周期阶段被释放的呢?在 Android 中, Window 是 View 的容器,而 WindowManager 则负责管理这些窗口,具体可以参考《View 绘制流程之 DecorView 添加至窗口的过程 》。

我们直接找到管理应用进程窗口的 WindowManagerGlobal,查看 DecorView 的移除工作:

/*** 将DecorView从WindowManager中移除*/
public void removeView(View view, boolean immediate) {if (view == null) {throw new IllegalArgumentException("view must not be null");}synchronized (mLock) {// 找到保存该DecorView的下标,true表示找不到要抛出异常int index = findViewLocked(view, true);// 找到对应的ViewRootImpl,内部的DecorViewView curView = mRoots.get(index).getView();// 从WindowManager中移除该DecorView// immediate 表示是否立即移除removeViewLocked(index, immediate);if (curView == view) {// 判断要移除的与WindowManager中保存的是否为同一个return;}// 如果不是同一个View(DecorView),抛异常throw new IllegalStateException("Calling with view " + view+ " but the ViewAncestor is attached to " + curView);}
}

根据要移除的 DecorView 找到在 WindowManager 中保存的 ViewRootImpl,真正移除是在 removeViewLocked 方法:

private void removeViewLocked(int index, boolean immediate) {// 找到对应的ViewRootImplViewRootImpl root = mRoots.get(index);// 该View是DecorViewView view = root.getView();// ... 省略// 调用ViewRootImpl的die// 并且将当前ViewRootImpl在WindowManagerGlobal中移除boolean deferred = root.die(immediate);if (view != null) {// 断开DecorView与ViewRootImpl的关联view.assignParent(null);if (deferred) {// 返回 true 表示延迟移除,加入待死亡队列mDyingViews.add(view);}}
}

可以看到调用了 ViewRootImpl 的 die 方法,回到 ViewRootImpl 中:

boolean die(boolean immediate) {// immediate 表示立即执行// mIsInTraversal 表示是否正在执行绘制任务if (immediate && !mIsInTraversal) {// 内部调用了View的dispatchDetachedFromWindowdoDie();// return false 表示已经执行完成return false;}if (!mIsDrawing) {// 释放硬件加速绘制destroyHardwareRenderer();} // 如果正在执行遍历绘制任务,此时需要等待遍历任务完成// 故发送消息到尾部mHandler.sendEmptyMessage(MSG_DIE);return true;
}

注意 doDie 方法(源码在前面已经贴出),它最终会调用 dispatchDetachedFromWindow 方法。

最后,移除 Window 窗口任务是通过 ActivityThread 完成的,具体调用在 handleDestoryActivity 方法完成:

private void handleDestroyActivity(IBinder token, boolean finishing,int configChanges, boolean getNonConfigInstance) {// 回调 Activity 的 onDestory 方法ActivityClientRecord r = performDestroyActivity(token, finishing,configChanges, getNonConfigInstance);if (r != null) {cleanUpPendingRemoveWindows(r, finishing);// 获取当前Window的WindowManager, 实际是WindowManagerImplWindowManager wm = r.activity.getWindowManager();// 当前Window的DecorViewView v = r.activity.mDecor;if (v != null) {if (r.activity.mVisibleFromServer) {mNumVisibleActivities--;}IBinder wtoken = v.getWindowToken();// Window 是否添加过,到WindowManagerif (r.activity.mWindowAdded) {if (r.mPreserveWindow) {r.mPendingRemoveWindow = r.window;r.mPendingRemoveWindowManager = wm;r.window.clearContentView();} else {// 通知 WindowManager,移除当前 Window窗口wm.removeViewImmediate(v);}}
} 

注意 performDestoryActivity () 将完成 Activity 生命周期 onDestory 方法回调。然后调用 WindowManager 的 removeViewImmediate ():

/*** WindowManagerImpl*/
@Override
public void removeViewImmediate(View view) {// 调用WindowManagerGlobal的removeView方法mGlobal.removeView(view, true);
}

即 AttachInfo 的释放操作是在 Activity 生命周期 onDestory 方法之后,在整个 Activity 的生命周期内都可以正常使用 View.post () 任务。

http://www.xdnf.cn/news/918217.html

相关文章:

  • rec_pphgnetv2完整代码学习(二)
  • 机器学习监督学习实战五:六种算法对声呐回波信号进行分类
  • [yolov11改进系列]基于yolov11引入轻量级下采样ContextGuided的python源码+训练源码
  • VBA之Word应用第三章第十节:文档Document对象的方法(三)
  • LeetCode--24.两两交换链表中的结点
  • Android USB 通信开发
  • 数组名作为函数参数详解 —— 指针退化及遍历应用示例
  • Oracle中的异常处理与自定义异常
  • Redis 与 MySQL 数据一致性保障方案
  • Ctrl-Crash 助力交通安全:可控生成逼真车祸视频,防患于未然
  • chili3d 笔记17 c++ 编译hlr 带隐藏线工程图
  • Jenkins持续集成CI,持续部署CD,Allure报告集成以及发送电子 邮件
  • STM32标准库-输入捕获
  • PySide6 GUI 学习笔记——常用类及控件使用方法(多行文本控件QTextEdit)
  • Redis高可用架构
  • CCPC chongqing 2025 H
  • PySide6 GUI 学习笔记——常用类及控件使用方法(单行文本控件QLineEdit)
  • Linux进程(中)
  • Java高级 |【实验八】springboot 使用Websocket
  • 174页PPT家居制造业集团战略规划和运营管控规划方案
  • 【android bluetooth 协议分析 15】【SPP详解 1】【SPP 介绍】
  • ThinkPHP 5.1 中的 error 和 success 方法详解
  • 【LangchainAgent】Agent基本构建与使用
  • 基于Spring Boot的云音乐平台设计与实现
  • Vue3 项目的基本架构解读
  • K8S认证|CKS题库+答案| 6. 创建 Secret
  • Gartner《How to Create and Maintain a Knowledge Base forHumans and AI》学习报告
  • 学习使用YOLO的predict函数使用
  • Android 平台RTSP/RTMP播放器SDK接入说明
  • 现代简约壁炉:藏在极简线条里的温暖魔法