避免在类设计上混合 Owner 及 View 语义

这个论断来自于前段时间重构 pickle 时的想法。

The Big Picture

Pickle 是一个二进制序列化设施,内部维护一块 buffer 用以保存被序列化的二进制数据,语义上看,一个 Pickle 对象对内部 buffer 具有无争议的 resource owner 语义。

反序列化设施 PickleReader 被设计为从一个给定的 Pickle 对象构造,并且在反序列化过程中只改变 PickleReader 内部的 reader cursor,不会对 Pickle 内部的 buffer 有任何 side-effect。显然,PickleReaderPickle 的内部 buffer 具有 view 语义。

为了支持能从一块给定的 raw buffer of serialized data 进行反序列化(这是很常见的需求,只要序列化后的数据涉及持久化操作,例如存储在磁盘上),Pickle 被设计为允许从给定的 read-only buffer 构造,以方便继续构造 PickleReader 对象进行反序列化操作;并且出于性能和逻辑考虑,Pickle 只维护指向这块 read-only buffer 的指针,并不拷贝一份 buffer 数据。

为了区分这种情况,在 read-only buffer 上构造的 Pickle 对象的 buffer capacity 始终为 size_t(-1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Pickle {
private:
struct Header {
uint32_t payload_size;
};
public:
Pickle();
// Creates a Pickle object that has weak-reference to a serialized buffer.
// The Pickle object cannot call any modifiable methods, and caller must ensure
// the referee is in a valid state, when PickleReader is applied.
Pickle(const char* data, int data_len);
// Makes a deep copy of the Pickle object.
Pickle(const Pickle& other);
Pickle(Pickle&& other);
// Makes a deep copy of the Pickle object.
Pickle& operator=(const Pickle& rhs);
Pickle& operator=(Pickle&& rhs);
~Pickle();
...
// Returns true, if this object is weakly bound to a serialized buffer.
bool readonly() const
{
return capacity_ == kCapacityReadOnly;
}
private:
static constexpr const size_t kCapacityReadOnly = static_cast<size_t>(-1);
Header* header_;
size_t capacity_;
...
};

不经意间,Pickle 被我加上了 view 语义。

What’s the Problem ?

问题始于对 assignment operators,尤其是 move-assignment 的修改。

一开始我发现我遗漏了对 evil self-assignments 的处理,加上一个非常简陋的 patch 之后我开始发觉事情没有这么简单。

assignments 的麻烦之处在于它涉及到两个方面的资源处理:原始资源和新资源。如果是 move assignment,那么还要考虑到被移动对象的资源的 ownership。

那么接下来考虑这样一个问题: 创建一个 owned pickle 对象,以及在这个 pickle 对象的 buffer 上创建一个 view pickle 对象,然后将 view pickle 对象赋给(copy and move) owned pickle 对象

注意,这种行为是合乎逻辑合乎语义的。因为 Pickle 具有值语义,无论是 owner 还是 view,都允许 duplication 行为。

接下来想一想,上面的操作会出现什么样的可能后果?以及,为了做到正确的行为,需要做什么样的 workaround?

你会发现你掉进了一个大坑,为了避免出现错误,你需要不断的修改你的 assignment 语义以适应可能出现的神奇情况。

如果赋值操作需要同时保证复制对象具有的 owner/view 语义,呃,这似乎是不可能的…

如果赋值操作只需要保证创建的 PickleReader 等效,那么可以在进行赋值前检查两个 Pickle 是否 ref 同一块数据内存,如果是,则直接返回。

但是注意,这个规定在语义上是一个很大的 degeneration。

当然还有其他 workarouond,但是实质上都不能摆脱它们只是不恰当语义下的丑陋的遮羞布。

而这一切的根本原因在于一个对象可能表现出 owner 语义,也可能表现出 view 语义;这实质上是 evil self-assignment 的一个延伸,即不同 resource 语义下 ref 同一个 resource。

想一想实现一个 copy-on-write 的 string 类可能要付出的代价,哪怕只考虑单线程。

一个 COW-string 能够从一个 view 语义对象悄悄的 transform 到一个 owner 语义对象;而这不起眼的 transformation 不意外的挖了一个大坑,没有经验的人可能直接跌得尸骨无存。

有兴趣的可以看看 Herb Sutter 在 More Exceptional C++ 中解决这个坑所引入的语义技巧。

对了,std::shared_ptrstd::weak_ptr 倒是很机智的用两个对象来表示 owner-view 语义。

Conclusion

这篇 post 的目的不是说完全不能这样设计,而是说在可能的前提下尽量拆分语义,毕竟挖了大坑把别人埋进去了不是一件值得炫耀的事情。

最后吐槽一下 chromium 组的某些工程师,虽然我承认 chromium 确实是一个成功的 C++ Project,但是他们有些人真的是不理解 C++,也只是把 C++ 当作一个加强版的 Java 来用。

Wicked Data Type Promotion

昨天在给 KAdBlockEngineAdFilter 加上序列化/反序列化的支持时,意外的又一次被 data type promotion 坑了,导致一晚上的时间都在 debug…

Yesterday-Case Once More

因为做 deserialization 时需要数据校验,所以在序列化到磁盘前会先对数据做一遍 MD5-checksum,然后再将 checksum 和实际数据分别写入磁盘。

类似的,反序列化时需要从读入的文件数据里分出 checksum 和实际数据,对实际数据再做一遍 MD5-checksum 后和上次保存的 checksum 进行比对。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kbase::Path snapshot_file(L"...");
std::string file_data = kbase::ReadFileToString(snapshot_file);
if (!file_data.empty()) {
kbase::MD5Digest checksum;
auto snapshot_data = file_data.data() + sizeof(checksum);
auto snapshot_data_size = file_data.size() - sizeof(checksum);
kbase::MD5Sum(snapshot_data, snapshot_data_size, &checksum);
if (std::equal(checksum.cbegin(), checksum.cend(), file_data.cbegin())) {
kbase::PickleReader snapshot(snapshot_data, snapshot_data_size);
AdFilter ad_filter = AdFilter::FromSnapshot(snapshot);
ad_filters_.push_back(AdFilterPair(filter_file, std::move(ad_filter)));
return;
}
}

于是神奇的事情出现了:当我用 std::equal 去比较两个 checksum 数据时,结果始终是 false,即使我在 debugger 的 memory watch 中确认了两块数据是一样的。

更神奇的是,顺手把 std::equal 换成 memcmp 之后,整段代码都能够正常工作。

看来问题出在 std::equal;But how come ?

Thinking and Resolving

一开始怀疑是 std::equal 的实现问题,于是自己用 for(...) 手工展开,结果却惊人地和 std::equal 一致,并且根据调试的反馈,应该是第一个字节比较就不等返回了。

这说明 std::equal 的实现是正确的,问题出在使用方式上。

于是下一秒瞥到了一个惊人的发现:MD5Digest 的数据类型是 std::array<uint8_t>,而 file_data 却是 std::basic_string<char>;而 char 在 MSVC 上是 signed data type。

直觉告诉我我掉到了 data type promotion 这个天坑里。

在 C/C++ 里对(unsigned) char/short 进行算术运算时,出于性能考虑,编译器会首先将数据扩展到 int(unsigned int),再进行实际的运算。这个过程通常称之为 promotion

但是问题来了,signed/unsigned 数据类型在扩展时使用的指令是不一样的。

考虑如下示例代码:

1
2
3
unsigned char lhs = -98;
char rhs = -98;
std::cout << (lhs == rhs);

首先,比较的结果肯定是 false,不然上下文就没意义了不是…

接下来看一下对应的汇编代码:

1
2
3
4
5
6
7
8
unsigned char lhs = -98;
00007FF60EA717DE mov byte ptr [lhs],9Eh
char rhs = -98;
00007FF60EA717E2 mov byte ptr [rhs],9Eh
std::cout << (lhs == rhs);
00007FF60EA717E6 movzx eax,byte ptr [lhs]
00007FF60EA717EA movsx ecx,byte ptr [rhs]
00007FF60EA717EE cmp eax,ecx

明显,对无符号类型的扩展使用的指令是 movzx,扩展时用0填充;对有符号类型的扩展指令是 movsx,扩展时使用符号位

这是 two’s complement 结构体系下的必然选择。

但是这里有个明显的坑,如果原始数据在有符号下是负数,亦即上例中的情况,那么扩展中会用符号位填充,扩展之后高位都变成了FF..;而无符号扩展则会使用0填充,扩展之后高位都是00..;这就导致扩展后的两个数据不想等。

回到一开始的坑。

因为 std::equal 默认会使用 std::equal_to<> 作为比较的 predicate,而 std::equal_to<> 等价于使用 == 进行比较;而比较的两方数据又分别是 charuint8_t,于是出现 promotion,导致错误。

memcmp 直接比较两块内存的 bits,不会发生 promotion。

Afterthoughts

其实 CSAPP 在某一章有专门提到 data type promotion 这个问题,不过时隔多日,真是转头就忘…

这个天坑说明一个道理,涉及无符合和有符号的整型比较时需要引起注意;而如果整型是 char/short 类型则更要注意。

最好的解决方案是避免写除混合类型比较且没有使用 cast 明确标识的代码。

这次为了偷懒直接用 std::string 保存内存块数据,最后也算是报应把… -‘’-

Template Type Constraints And Type Traits

模板参数类型约束在 C++ 中一直以来大概得算不上不下的一个处境。

因为非运行时(编译期)的优势,对于参数类型约束的需求不像 C# 那样紧迫;但是同时又因为实例化完成的时期过早,编译器对于模板代码的处理并不能上升到一个精确的语义层次,导致的后果就是模板相关的代码的出错信息一直在井喷,而且相当一部分的错误信息完全没有卵用。

最直接的例子就是拿 std::sort 去对一个 std::list 对象进行排序。

早期的编译器扔出来的错误提示第一眼完全看不出再讲什么(例如找不到 operator<的定义);虽然经验丰富的老司机们一下就能看出问题在于 std::sort 要求的迭代器是 random access iterator, 而 std::list 提供的迭代器是 bidirectional iterator,但是原因八成是因为老司机们撸过的轮子比你用过的库还多。

这个问题对于 library developer 尤为尖锐。

按照原本的历史进程,解决这个大麻烦的荣耀应属 C++17 中的 Concepts;然而不知道标准委员会的委员们哪根神经回路短路了,居然把这个 proposal 给 veto/reject 了,于是之前说好的大救星就这么突然被打脸了。

Type Constraints

在很久很久以前,甚至比提出 concepts 这个想法还要早,曾经有一批人开始尝试自己加一些类型约束设施,已达到类似的要求。

在 Herb Sutter 著名的 More Exceptional C++ 一书中,提供了这么一个例子:

假设我有一个类模板

1
2
template<typename T>
class Foo { ... };

要求 Foo 的实例化类型参数 T 必须包含一个成员函数 Clone(),并且这个函数的原型要严格满足 T* Clone() const

做法是提供一个类型约束类 HasClone

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename T>
class HasClone {
public:
HasClone()
{
void (*pfn)() = &ValidateConstraints;
}
~HasClone() = default;
DISALLOW_COPY(HasClone);
DISALLOW_MOVE(HasClone);
static void ValidateConstraints()
{
T* (T::*fn)() const = &T::Clone;
UNREFED_VAR(fn);
}
};

并利用父类构造函数先于子类执行的特性

1
2
3
4
template<typename T>
class Foo : HasClone<T> {
...
};

对模板参数 T 进行约束。

虽然在形式上这个构造法非常漂亮,然而在实践中,根本称不上好用。

某些编译器,例如 VS 2015,在显式调用 T::Clone() 失败时会直接跳过 HasClone 的构造,直接提示目标调用点相关的错误。

这意味着精心设计的类型约束类直接变成了 subordinate solution。

Type Traits

另一方面:既然不方便改善类型约束,那么可以考虑在编译期利用类型信息针对不同的类选择不同的行为。

于是这个操作通常需要两部分协助: type traits 和 template specialization。

举个很常见的误用 OOP 的例子:有两个类型 A 和 B,如果 B 是 A 的子类,那么进行操作1,否则进行操作2。

有些人会利用 runtime type information 的方式实现,显著特征是要做 down-cast,例如 dynamic_cast 或者 Java 中的 instanceof,然而他们不知道这是 runtime polymorphism 的典型场景。

不过在 C++ 中,借助 type traits,可以实现 compile time polymorphism。

这里我们需要一个能够编译期判断继承关系的设施,假设它是一个名为 IsDerivedDrom 的类。

同样来自 More Exceptional C++,这个实现来自脑洞炸开的 Andrei Alexandrescu:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename D, typename B>
class IsDerivedFrom {
private:
struct No {};
struct Yes { No data[2]; };
static No Check(...);
static Yes Check(B*);
public:
enum {
value = sizeof(Check(static_cast<D*>(0))) == sizeof(Yes)
}
};

这个实现有多精巧呢?

  1. 继承关系通过非常自然的 implicit upcast。如果 DB 的子类,那么 D* 能够安全顺滑的转换为 B*
  2. 利用函数重载来实现编译期绑定,并且利用了不定参数形式在 overload resolution 中的 low priority
  3. sizeof 和 enum 的编译期求值行为
  4. 没有任何运行时开销

从头到脚的 compile-compiliant 的处理方式,闪耀着老司机们的智慧光芒。

当然到了 C++11,可以把这个形式做更进一步的简化,让直观上更容易理解

1
2
3
4
5
6
7
8
9
template<typename D, typename B>
class IsDerivedFrom {
private:
static std::false_type Check(...);
static std::true_type Check(B*);
public:
static constexpr const bool value = decltype(Check(static_cast<D*>(nullptr)))::value ;
};

至于剩下的怎么做 specialization 这里就不提了,自己翻书去吧。

一个好消息是 C++ 11 开始,STL 内置了一大票和这个类似的 type traits 设施。

在 concepts 短期无望的情况下,利用 type traits 和 specialization/overload resolution 可以做出一些更加灵活的设计,并且依托内置设施,甚至大程度可以避开自己手工跑 SFINAE,减轻生活压力。

大概就这样。

PS:不过其实你最后可以发现,两者前后并没有什么直接的因果关系。主要原因大概还是为了写个 post 而丧心病狂的强行拉亲戚。

为什么我反对使用 git flow

如果论知名度,git flow 绝对是现在数一数二的 git 协作模型。甚至直到不久之前,我也在用这个模型作为我在 github 上的几个项目的工作流。

但是一段时间后,我越来越觉得这个 work flow 不仅过于冗余、繁琐,而且在实际操作中充满了艰险。这种想法在我到新公司后,和多个同事(5-7人)一起协作开发一款 android 直播客户端时达到顶峰。

从核心点来看,git flow 的最大亮点是:

  • 一个始终稳定且代表可发布产品时刻的 master 分支
  • 各自独立的开发分支,例如各种 feature 分支

坑在何处

对于客户端项目来说,稳定可发布的 master 分支完全没有什么意义。因为客户端没有所谓的线上环境,产品发布有自己单独的发布渠道(例如应用市场,官方主页等),这部分版本更迭完全是天然隔离的。

而服务端项目,虽然确实需要一个稳定的,对应线上环境的分支,但是其他 work flow 也完全可以做到。例如一个简单的 master 分支和对应开发的 develop 分支。

其次,git flow 第二个所谓亮点会引发一个很严重的后果:后期合并代码时连锁的冲突问题。

git flow 最大的亮点导致的一个核心问题就是,大家在各自独立的分支上展开工作,工作完毕了才合并到 develop 分支,导致每个人距离共同分支(他人修改)太远

这种对彼此的代码修改互相不可知的工作流程,在后期合并代码时简直是噩梦。通常来说,需要有一个专门的人负责合并代码,并且合并某个分支时,对应分支的开发人员要在场,否则对于大块大块陌生的代码,完全没法进行合并操作。

于是你就会发现,在这一期需求的收尾阶段,你浪费了大量的时间和精力在合并代码上,并且还伴随着潜在的合并出错的风险。

上一周不到三天的时间内,我已经做了两次修改远程仓库的历史,抹掉新来的同事提交的大块修改代码导致的问题,这只能用灾难来形容。

现在回过头来想想 git flow 这种独立多 feature 到底有效解决了什么问题?答案是有效解决了一个人为臆想的问题,即:某个要做的需求大大超前于产品的版本。

就现在的环境而言,出现这种需求,99%的情况下是产品需求不合理。所以正确的做法是,把产品按在地上暴打一顿。

化繁为简,返璞归真

个人建议的 git work flow 其实早在 SVN 时代就用的很普遍了:我们只需要一个用于开发的 master 分支。

对于客户端,只需要一个这样的 master 分支即可,每次到发版本,切出一个发布分支,并在 master 上打上 tag 即可。

而对于服务端,一个稳定的 master 分支,加上一个开发的 develop 分支即可。

这种模式下,每个人需要把修改 push 到远程分支时,都需要利用 fetch && rebase 同步他人修改,而就算此时发生冲突,冲突的范围也绝对是能在控制之中。

所以这里的核心就是:减少和公共分支的距离,尽量缩小冲突的大小

Pop quiz: 多次解决小冲突比起一次性解决大冲突会有优势吗?

妈的简直废话,你没学过什么叫 divide/decrease-and-conquer 吗?

git flow 这种多分枝模型,给人的感觉就是,because I can,典型的不考虑实际生产环境。

多说一句,chromium 这种巨型项目,用的就是一个稳定的 master 分支这种工作流。难以想象如果他们用 git flow,最后冲突会出现什么样天崩地裂的景象啊。

记一次被 Android 进程复用坑的经历

因为需求需要,把某个功能拆分成一个独立的服务,并由一个全局的 service manager 去控制这个服务;服务对客户端暴露的实现也是通过 service manager

因为服务不需要运行在一个独立进程,manager 和 service 直接通过一个包含服务对象的 local binder 相互通信,看上去大概就这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class LocalService extends Service {
public class LocalBinder extends Binder {
LocalService getService() {
return LocalService.this;
}
}
@Override
public IBinder onBind(Intent intent) {
return mBinder;
}
private final IBinder mBinder = new LocalBinder();
}
// This class is in fact a singleton.
public class ServiceManager {
private LocalService.LocalBinder mServiceBridge;
private ServiceConnection mConnection = new ServiceConnection() {
public void onServiceConnected(ComponentName className, IBinder service) {
mServiceBridge = (LocalService.LocalBinder)service;
}
public void onServiceDisconnected(ComponentName className) {
mServiceBridge = null;
}
};
public void startService() {
bindService(...);
}
public void stopService() {
unbindService(...);
}
}

考虑到语义的逻辑,我一开始选择了在自定义的 MainApplicationonCreate 中启动服务;并且为了避免不必要的消耗,在用户从主 activity 退出时,会停止这个服务。

过了几天,有同事在测试过程中反馈说,每次正常从 app 退出后,再次打开 app 运行涉及服务的功能都会抛 mServiceBridge 的空指针访问异常,但是下一次打开 app,相关功能又是好的。

我一开始觉得莫名其妙,因为对象空指针只能意味着服务没有被启动,但是启动服务是在 Application.onCreate 中进行的,这说不过去。

后来我不小心瞥到 DDMS 的进程列表,发现即使关闭了所有的 activity 退出了应用,相应的进程并没有退出…正如牛逼顿被苹果砸到的那个瞬间,一个想法一闪而过。

经过 demo 验证,问题出在 android 的进程复用上。

和传统 PC 上的做法不同,考虑到移动设备的特殊环境,即使所有的 activity 退出了,系统也不会马上回收 app 所在的进程。进程的资源、状态仍被保留,只是会进入一个不稳定状态:系统随时都能回收这个进程。

而如果在进程被系统回收前又打开了 app,那么系统会复用之前的进程。因为进程并没有被重新创建,所以 onCreate 函数被跳过了。在上面的环境下,就会跳过启动服务,导致 mServiceBridge 对象为空。

解决方案有两个:

  • 把启动服务的操作放到某个 activity 启动中,例如 the infamous splash activity
  • 主 activity 结束后强行退出进程

另外,根据上面的情况起码要明白,如果某个行为(例如一些设施的初始化)要放在 Application.onCreate 中完成,那么语义上这个操作形成的作用是跟随整个进程生命周期的;而进程的生命周期在 android 上是不可知的,所以相应的,这个行为的设计也要考虑到这点。

自己轮的 string_view 和它的小伙伴 tokenizer

String View

string_view 是 C++ 17 引入的一个基础设施,和 array_view 类似,表示一种 non-owning object

实际上,StringPiece 也是一种类似 string_view 的实现,并且一开始也作为提供 string_view 的范例实现之一出现在相关的 proposal 中。

但是既然标准委员会已经钦定了 string_view,我们就应该用它。

不过 VS 2015 update 2 附带的 STL 实现中并没有包含 string_view,于是出于其他需求,我做了如下两件小事:

  1. 按照标准要求,自己轮了一个 string_view实现
  2. 移除了 StringPiece,并用自己实现的 StringView 替换。

不过自己实现的代码,代码风格还是按照自己的偏好。

Tokenizer

某个需求是这样的:从文本读入一段数据,数据用固定标记分隔成一段一段,需要对每段数据做相关处理。

一种直接的方式是利用 kbase::SplitString 将数据分段。但是 SplitString 会一下将所有数据分段,在数据量很大的情况下会消耗不小的空间。而如果每段数据前后没有依赖关系,那么只要每次能获取到那一段的数据即可。

Tokenizer 就是在这种情况下被实现出来的。

Tokenizer 依赖前面提到的 StringView,因为它实际上也是一个 non-owning viewer,不会对原始数据造成任何可修改的影响。

Tokenizer 利用提供的 iterator 实现对数据段的遍历,并且利用标准的 begin end 可以直接支持 C++ 11 的 ranged-base for。额外的好处是让编译器对和 end() 比较的代码进行优化。

虽然从标准而言,begin()end() 应该具有常数时间复杂度,但是在以 token 为目标数据的条件下,begin() 很难做到常数时间复杂度,一般情况下需要 $O(l)$ 的复杂度,其中 $l$ 为数据段长度。不过好在初始调用并不频繁。

一个简单的范例看起来大概是这样的

1
2
3
4
5
6
7
8
9
10
11
std::string str = "anything that cannot kill you makes you stronger.\n\tsaid by Bruce Wayne\n";
std::vector<std::string> exp { "anything", "that", "cannot", "kill", "you", "makes", "you",
"stronger", "said", "by", "Bruce", "Wayne" };
Tokenizer tokenizer(str, " .\n\t");
size_t i = 0;
for (auto&& token : tokenizer) {
if (!token.empty()) {
EXPECT_EQ(exp[i], token.ToString());
++i;
}
}

具体实现见这里

推荐 google samples for android-architecture

在 Android 正式支持 data binding & MVVM 之前,MVP 可以算是最好的 android app 架构模式。

但是直到前不久,Google 才在 github 上提供了推荐的 android-mvp 做法。

虽然现在能找到各式各样实现 MVP 的 blog posts 和 samples,但是不得不说 Google 这次提供的 sample 依然惊艳十足,并且还是来自官方钦定。

Sample 将 view layer 和 presenter layer 需要协定的接口写到了一个单独的 xyz_contract 文件里,view 和 presenter 都需要实现各自的接口。

Sample 中 view layer 对应的是具体的 fragment,activity 只是充当了一个 startup facility。我觉得这个里可以做一个灵活的选择:如果引入 fragment 是没有必要的,那么可以直接将 view layer 放到 activity 中。

Model layer 的实现到可以不用那么讲究,如果项目中提供了 dependency injection 的设施,或者需要单独对 model layer 做 unit test(一般有这种需求手头应该有支持 DI 的设施了),则可以在 presnter – model 间协定一个 contract,present 持有实现了 contract 的对象,做到进一步解耦。

反之,如果不需要在 model layer 有太多的变化余地,则可以直接把具体的 model object 暴露给上层的 presenter。

讲真,如果没有 DI 协助,多一个 model contract 其实也挺多余。

Google samples 采用的是混合方式…看代码的话就知道了。

为了更快速的熟悉 google 推荐的做法,我花了点时间做了一个 login demo。view 直接放在了 activity,并且 model 直接暴露给了 presenter。

举个例子:

因为 view 中不能包含除了 UI 设置以外的代码,所以 login button 是否可用状态是 view 层监听输入框消息,并且将事件 delegate 到 presenter,presenter 通过判断后通知 view(调用 view 暴露的接口),改变按钮状态。

整个流程拆解后虽然变得繁琐,但是责任却变清晰了,所以在业务逻辑变得复杂的时候,这种 view – presenter 的拆分会逐渐体现出优点。

不过很明显,如果采用 MVVM,上面的实现会变得更加优雅、简单。

最后还是推荐一下 android devs 看一下这个 demo(虽然我不是很想做 android app -‘’-)。