C++ SDK 设计经验总结

前一段时间用c/c++开发了一个Android SDK,中间踩过很多坑,最后做了一版重构版,总结了一些经验教训。

接口设计

接口设计有以下几点我认为很重要:

1. 保持简洁

要把我们的 SDK 用户当做小白,他们基本不会阅读你的文档,也不会看我们的代码注释,只会将 demo 里的代码复制粘贴到他们自己的工程里。所以我们在设计接口的时候一定要多站在小白用户的角度来考虑怎样才能把接口设计得足够简洁,从而减少我们很多的后期支持操作。

2. 保持稳定

这一点应该很好理解,如果接口不稳定,每次更改接口必然会导致用户的抱怨,他们也必然会产生各种各样的集成问题,我们就必须做好增加支持工作量的准备,双方都很不happyㄟ( ▔, ▔ )ㄏ

3. 可扩展

不可扩展的接口在面对新的需求的时候要么修改原来的接口,要么添加新的接口。同第二条一样,等待迎接客户暴风雨般的抱怨和求助吧。
要做到以上3点,我们可以这样来设计接口:即将有关联的数据或者返回值用一个结构体进行封装,这样接口的参数只有结构体,即使以后有什么需要修改的地方,只要把结构体做一下修改即可。

1
2
Status (*compare)(Handle handle, const Image* image,
CompareStat* compare_stat);

如上面的这个接口需要传入一个Handle和 Image的结构体指针,输入的内容通过CompareStat结构体输出。这两个结构体我们可以这样设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct InpBlob {
const void* buf;
size_t size;
} InpBlob;

typedef struct Image {
InpBlob data;
int width, height;
} Image

typedef struct CompareStat {
int compare_score;
int compare_count;
...
} CompareStat;

在结构体Image内部还包含另外一个结构体InBlob来包含传入的图像数据。这样设计的接口不仅简单明了,而且还很容易扩展。

4. 可配置

永远不要低估客户提出各种需求的决心,如果用户的需求我们现在的接口无法满足的话,那就只能修改接口了。所以我们一定要提供一个配置接口,尽最大地可能让用户根据他们自己的需求来对 SDK 进行配置。当然不要忘了提供一个默认配置

5. 对外暴露C符号

C++的接口在编译后会对符号加上一些更改,所以如果用户用的C++版本和我们的不一致的话肯定会导致各种方法找不到的问题。最好给用户暴露C的符号,这样不论C++的版本如何都不会产生上面所说的问题。

模块划分和测试

要开发一个质量高的 SDK 完善的测试时离不开的,这里的测试是指高质量的自动化 testcase。 而良好的模块划分是书写高质量 testcase 的基础。想象一下各种不同的功能都放在同一个模块里互相依赖,我们要怎么写单元测试的 testcase 呢? 把模块划分好,不仅有利于多人合作也有利于 testcase 的书写。当所有的模块都伴随着 testcase 书写完成的时候,将它们组装起来就是一种非常容易的事情了。所以,在开始开发前先把功能想清楚,把模块划分好,磨刀不误砍柴工。

关于 C++ testcase 的书写我们可以使用 gtest

如果想将 sdk 跑在 Android 手机上可以使用 termux

一些小技巧

1. 巧妙统计方法执行时间

如果 SDK 对性能有比较高的需求的话那统计主要方法的执行时间找出性能的瓶颈就很有必要了。那么要怎么来统计呢?一个很容易想到的办法就是在方法开始执行的时候记录下一个时间,然后在方法返回前计算一下时间差并输出来。但是这样有一些弊端,一方面比较繁琐,另一方面如果一个方法有多个地方会 return 那就会让人比较崩溃了,可能需要在每个return 前面都加上统计时间差的代码。作为一个懒人表示这是不可容忍的。

考虑到方法的执行就是进栈和退栈的过程,即方法执行的过程中,如果创建一些非 new 出来的临时变量,这些变量都会被压入到栈中;而当方法执行完毕的时候,栈会回退到方法执行前的状态,所有在方法内用非 new 的方式创建的临时变量都会被自动回收。所有我们可以定义一个 ScopedTimer 类,在其构造方法里记录一个开始时间,然后在其析构函数里统计时间差并输出。这样我们只要在方法的最开始创建一个 ScopedTimer的对象就能达到统计方法执行时间的目的了。参考代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ScopedTimer {
const char* m_name;
Timer m_timer;

public:
ScopedTimer(const char* name);
~ScopedTimer() noexcept;
};

ScopedTimer::ScopedTimer(const char* name) : m_name{name} {
log("enter %s", name);
}

ScopedTimer::~ScopedTimer() noexcept {
log("time:%s: %.3fms", m_name, m_timer.get_msecs());
}

2. 通过宏检测函数返回值

虽然现在C++开发并不推荐使用宏了,但是有些情况下用好了还是很方便的。例如我们需要检测一下调用函数的返回值,在返回值异常的时候终止当前方法的执行并返回异常的返回值,我们可以这样做:

1
2
3
4
Status result = check_quality();
if(result != OK){
return result;
}

可能一个方法还好说,如果要检查很多函数的返回值的话我们就不得不重复写这些无聊的代码。利用宏我们可以巧妙地把自己从这种重复性的工作中解放出来。我们可以定义如下所示的宏:

1
2
3
4
5
6
7
#define CHECK_RET(_expr)          \
do { \
Status _s = (_expr); \
if (_s != OK) { \
return _s; \
} \
} while (0)

通过这个宏可以在检查返回值的同时将所有异常的返回值直接返回,下面我们就可以很轻松地做这种检查了,是不是感觉很清爽?

1
2
3
4
5
6
7
8
9
10
11
Status check_quality() {
CHECK_RET(check_1());
CHECK_RET(check_2());
CHECK_RET(check_3());
CHECK_RET(check_4());
CHECK_RET(check_5());
CHECK_RET(check_6());
CHECK_RET(check_7());
CHECK_RET(check_8());
return OK;
}

3. 使用智能指针代替裸指针

所谓的裸指针就是直接定义并使用指针如下所示:

1
Handler* m_handler;

使用裸指针很容易出现忘记释放内存的情况,造成内存的泄漏而且还难以查找根源,所以我们最好忘掉裸指针,直接使用智能智能指针来代替。如上面的指针用智能指针可以写成下面的形式。这样我们就再也无需操心内存的释放问题了。

1
std::unique_ptr<Handler> m_handler;