故事点介绍
在 Tumax 项目中,作为编辑器存在非常多的“视图交互”,其中关于显隐就是一块状态复杂、由多重因素影响的交互重灾区。
技术难点
随着项目的不断迭代,业务需求的不断增多,隐藏的交互事件也逐渐增多(墙体隐藏,吊顶隐藏,地面模型隐藏,硬装下隐藏,选择房间后隐藏,挂墙、门窗跟随隐藏,不同路由显隐不同),这么多显隐逻辑混在一起,逐渐发现我们以前原有的显隐逻辑变得异常混乱。
举个简单的例子:
在户型导航的逻辑中,要求对选中的房间以外的所有模型数据都隐藏,当我们执行这样的操作后,表现正常
在墙体隐藏的逻辑中,要求遮挡观察实现的墙体全部隐藏,当不遮挡视线后再恢复显示
- 选择单房间,转动相机,被户型导航隐藏的墙体显示了出来。
- 选择单房间,转动相机,返回完整户型,被摄像机隐藏的墙体显示了出来。
- 选择单房间,上下移动相机,其他房间的地面模型被显示了出来。
- 选择单房间,切换到顶面路由,其他房间的顶面模型被显示了出来。
- 在 2D、3D 同时存在的页面下,转动相机,2D 下的模型显隐跟随 3D 摄像机转动变化。
解决思路
在上述例子中,可以感受到如果单凭数据模型中的一个visible
状态,来处理模型的显隐逻辑是远远不够的。
那么究竟应该如何解决这么多交互混合应用之后的显隐逻辑呢?
吉德林法则:把难题清清楚楚地写出来,便已经解决了一半。只有先认清问题,才能很好地解决问题
首先,遇到问题需要搞清楚问题到底是什么?
上面列举的问题例子都是属于个别 Bug,如果针对于这些 Bug 做对应的处理修复,就会陷入无休止的 Bug 循环,改了户型导航影响墙体隐藏,改了墙体隐藏影响路由隐藏,真正变成“捉襟见肘”。
所以,为了解决根本问题,需要分析得出最根本的印象因素。通过验证每个 case,我列出以下几个会影响任意模型显隐的影响因素。也就是说,在项目中的任意一个物体的显隐,都是以下几个因素共同作用下的结果。
影响因素:
通用生效(C):
- 总开关
- 选中的户型导航
仅 3D 生效(S3):
- 3D 开关
- 摄像机
仅 2D 生效(S2)
- 2D 开关
组合 Case
下面会列举出项目中所有的显隐 Case,以下所说的所有家具,包含组合家具。
公式解释:
S2.1:boolean ==> 2D影响因素1(2D 开关),值可能为true or false
C1.1:false ==> 通用影响因素1(总开关),值为false,也就是无论如何都不显示
S2.1:? && C.2:? ==> 显隐受 2D开关(S2.1)及户型导航(C.2)共同作用,只有两个值都为true时才显示
2D 设计路由
- 地面家具:
S2.1:? && C.2:?
==> 在 2D 路由下,地面家具显隐需要地面家具开关及户型导航都为 true - 吊顶家具:
S2.1:? && C.2:?
- 天花家具:
S2.1:false
3D 设计路由
- 地面家具:
C.2:? && S3.2:?
- 地面家具:
C.2:? && S3.2:?
- 墙体显隐:
C.2:?
- 墙体透明:
S3.1:? && S3.2:?
==> 3D 墙体分为透明及隐藏,透明有 3D 开关及摄像机决定
硬装设计路由
硬装路由下,存在 2D 场景与 3D 场景同时存在的情况,且显隐规则不同。所以需要分别列出他们的显隐规则。
3D 场景通用规则
- 地面家具:
C.2:? && S3.1:? && S3.2:?
==> 3D 地面家具显示需要家具开关、户型导航、摄像机
都为true
- 吊顶家具:
C.2:? && S3.1:? && S3.2:?
- 墙体显隐:
C.2:?
- 墙体透明:
S3.1:? && S3.2:?
2D 场景
顶面(硬装)路由:
- 地面家具:
S2.1:false
==> 顶面路由下,不显示 2D 家具 - 吊顶家具:
S2.1:? && C.2:?
地面(硬装)路由:
- 地面家具:
S2.1:? && C.2:?
- 吊顶家具:
S2.1:false
==> 地面路由下,不显示顶面路由
墙面(硬装)路由:
- 地面家具:
S2.1:fasle
==> 墙面路由下,不显示任何家具 - 吊顶家具:
S2.1:false
渲染路由
3D 场景
- 地面家具:
C.2:? && S3.1:? && S3.2:?
==> 3D 地面家具显示需要家具开关、户型导航、摄像机
都为true
- 吊顶家具:
C.2:? && S3.1:? && S3.2:?
- 墙体显隐:
C.2:?
- 墙体透明:
S3.1:? && S3.2:?
2D 场景
地面模式:
- 地面家具:
C.2:?
- 吊顶家具:
S2.1:false
顶面模式:
- 地面家具:
S2.1:fasle
- 吊顶家具:
C.2:?
复合状态
理清楚了上述所有的显隐规则后,所要解决的就已经变得清晰明了 了。原来,场景中的每个物体的显隐visible
都取决于这几种影响因素的共同作用。
那么就需要有一套显隐规则来作为visible
的计算依赖,代码实现:
interface IOptions {
camera?: boolean;
navigation?: boolean;
switch?: boolean;
switch2D?: boolean;
switch3D?: boolean;
}
const DEFAULT_OPTIONS: IOptions = {
camera: true,
navigation: true,
switch: true,
switch2D: true,
switch3D: true
};
export default class VisibleRuler {
public camera: boolean; // 相机位置
public navigation: boolean; // 户型导航
public switch: boolean; // 总开关控制
public switch2D: boolean; // 2D开关
public switch3D: boolean; // 3D开关
constructor(options?: IOptions) {
this.init(options);
}
public get visible2D(): boolean {
return this._navigation && this._switch && this._switch2D;
}
public get visible3D(): boolean {
return this._navigation && this._switch && this._camera && this._switch3D;
}
public init(options: IOptions = {}) {
options = {
...options,
...DEFAULT_OPTIONS
};
Object.keys(options).forEach(key => {
if (Object.hasOwnProperty.call(this, key)) {
this[key] = options[key];
}
});
}
public copy(visibleRuler: VisibleRuler) {
Object.keys(DEFAULT_OPTIONS).forEach(key => {
if (Object.hasOwnProperty.call(this, key)) {
this[key] = visibleRuler[key];
}
});
}
}
如果原来在数据模型中的visible
,如下:
class Model {
position: Vector3;
scale: Vector3;
rotate: Euler;
matrix: Matrix4;
visible: boolean = true;
...
}
就需要变为:
class Model {
position: Vector3;
scale: Vector3;
rotate: Euler;
matrix: Matrix4;
visibleRuler: VisibleRuler = new VisibleRuelr();
get visible3D(): boolean {
return this.visibleRuler.visible3D;
}
get visible2D(): boolean {
return this.visibleRuler.visible2D;
}
}
接着再加入Mobx
,实现数据响应:
import { action, computed, observable } from "mobx";
export default class VisibleRuler {
@observable
public camera: boolean; // 相机位置
@observable
public navigation: boolean; // 户型导航
@observable
public switch: boolean; // 总开关控制
@observable
public switch2D: boolean; // 2D开关
@observable
public switch3D: boolean; // 3D开关
constructor(options?: IOptions) {
this.init(options);
}
@computed
public get visible2D(): boolean {
return this._navigation && this._switch && this._switch2D;
}
@computed
public get visible3D(): boolean {
return this._navigation && this._switch && this._camera && this._switch3D;
}
@action
public init(options: IOptions = {}) {
options = {
...options,
...DEFAULT_OPTIONS
};
Object.keys(options).forEach(key => {
if (Object.hasOwnProperty.call(this, key)) {
this[key] = options[key];
}
});
}
}
在改造完成后,那么,如果现在需要改变一个模型的显隐需要如何去做呢?
如摄像机高度高于当前楼层高度,则修改所有吊顶类模型的visibleRuler.camera
,紧接着 Mobx 中的computed
将会被触发重新计算,如果计算结果与上次的显隐状态不同,Mobx 将会触发所有订阅的更新,用于同步到对应的 2D、3D 需要显示显隐状态。
import homeModel from "../store";
const toggleCeilingModel = (b: boolean) => {
const ceilingModels: Model[] = homeModel.curLevel.getCeilingModels;
ceilingModels.forEach(model => model.visibleRuler.setCamera(b));
};
结语
以上内容均为实际项目中所遇到的问题,解决思路过程及时间占比大概分为:
- 梳理问题,帮助产品理清显隐需求点(40%)
- 编写代码(10%)
- 测试、修改 影响点(50%)
这个问题在项目中反复出现、反复修改,每次“捉襟见肘”的修改都可能会出现下一次的 Bug,甚至在项目代码中出现以下注释:
从解决问题的时间占比可以看出,在解决一些问题的过程中,编写代码的部分反而是占比最少的。
大量的时间是花费在与产品、测试沟通,编写完成后对项目的影响点测试、修改上面。
有人会问,为什么不在一开始设计的时候就做好呢?这样后面不就没这个问题了?
我认为设计是 Line,而非 Point。设计是一个持续的过程,有新的需求过来,在当前的设计下无法满足需求,就需要思考进一步的设计,在不断的理解中寻求好的设计与解决方案。
只有这样作为工程师才有差异化的竞争力与价值点,只有这样的工程师才能拥有自己的技术人力模型。
对于项目中的技术债务、项目中的污点,想要称为一名高级/资深工程师应该具有拥抱变化、解决根本问题、思考更优设计的能力和动力。