MMAP 实现了一个可以跨进程通信打Boss的组件

580 阅读8分钟

始于足下,践于键盘之间

通过前面的学习,趁热打铁,现在就是履行实践的最佳时机。

我们知道:要用MMAP实现一个跨进程通信,只需要调整对mmap函数参数flags进行适当调整即可。

那么接下来由我带大家实现一个简单的框架,并以打游戏的方式向你们一一介绍。

上号

不啰嗦,上号大家都会,不会就点左上角❎

创号

选择Native C++,下一步

输入账号名称,Next

选择C++ 11,喜欢新特性就选择高一些,我猜你也用不到,所以干脆11,点Finish,创号完成。

整理装备

简单梳理下资源,顺便再整理下从头到脚的装备,不能裸奔不是

准备齐全后,教你一个万变不离其宗的设计技巧,那就是分层,划分界限,捋清职责,统一风格(不要五颜六色,切记)。

  • MMAPIPC JAVA APP 最上层,创建两个Activity,一个在主进程,一个在子进程,App负责初始化mmipc组件
  • MMAPIPC JAVA LIB 第二层,创建mmipc module,专门负责通信模块的封装,提供统一的接口
  • MMAPIPC JNI 第三层,负责对接native的具体实现
  • MMAPIPC NATIVE 最后一层,负责进程通信具体实现

开始打怪

先打小怪,捏柿子肯定要先挑软的对吧,这是基本常识

创建打怪的角色完成后,给OtherProcessActivity配置下子进程

然后创建App,负责初始化Boss对象,后面就可以打他

创建Module mmpic,副本的关卡入口

到这里我突然想起来,前面创建App,其实不用创建带Native的项目,好吧,你知道就行了,我就跳过了,人岁数大了就容易妥协,本人换账号是不可能了,都练了二三十年了

赶紧扫清障碍,把路铺好,准备打Boss了

路已打通,开始最后的挣扎吧,我一般是在见Boss前,加满血

改下名字,职责单一,保持统一,然后创建我们的大Boss,MMIPC

等等,作为一个资深的游戏玩家,我们需要做个日志备份,防止中途失败后,找不到原因,把日志准备好了,这下可以安心去打Boss了。

干Boss

通过前辈留下的印记,我们发现Boss主要有三个技能

  • initMMAP
  • setData
  • getData

好,我们打Boss前主要就是,见招拆招,只要能躲过这三个技能,我们就可以无伤通关,下面开始拆招

拆招

同样我们通过前辈的了解,发现boss mmap有六个参数

void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
  • addr 代表映射的虚拟内存起始地址;
  • length 代表该映射长度;
  • prot 描述了这块新的内存区域的访问权限;
  • flags 描述了该映射的类型;
  • fd 代表文件描述符;
  • offset 代表文件内的偏移值;

关键在于对length、prot、flags、 fd四个参数的调整,来让boss变化形态。boss的核心设计如下

m_ptr = (char *) ::mmap(m_ptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
  • m_ptr 将boss指针交给它,后面通过它来打boss
  • m_size 设计boss怒气值,怒气值满了就死掉,不是血条哈,因为这个boss免疫任何伤害,但就怕被人给它东西,一给东西就涨怒气值,自己气自己,你说气人不
  • PROT_READ | PROT_WRITE,设计可读可写
  • MAP_SHARED,设计共享
  • m_fd,boss真身的位置,这个boss比较狡猾,喜欢保护自己,如果突然断电了,这boss能跑掉,你只能下次宰它了

这样一来,我们的boss对象就有着落了,为了打他方便,我们把所有变量都存起来,然后设计一些工具方法来揍他

class MMIPC {
    int m_fd = -1;  //文件句柄,boss的真身
    string m_path;  //文件的路径,用于初始化文件句柄,boss真身的位置
    char *m_ptr;    //mmap内存的指针,boss的替身,对它的伤害最终会作用到真身上
    size_t m_size;  //mmap内存映射大小,boss的怒气值,越打越大,满了会自杀,估计是被气死了
    size_t default_mmap_size; //系统规定的默认缓存页大小,怒气值的大小限制就靠它了
    size_t m_position = 0;  //当前mmap内存数据记录的位置,当前怒气值位置

public:
    ~MMIPC() { doCleanMemoryCache(true); } //对象销毁时自动调用

    void doCleanMemoryCache(bool forceClean); //boss死的时候要清理战场

    bool open();//链接Boss真身

    void close();//关闭真身的链接,可以打扫战场了

    bool truncate(size_t size);//刷新怒气值

    bool mmap();//构建怒气值槽

    void reloadMmap(const string &path);//多个人来打boss时,重新链接boss

    void setData(const string &key, const string &value);//打他

    string getData(const string &key, const string &value);//同步怒气值给别的人

    bool isFileValid() { return m_fd >= 0; }//检查这个boss能不能被打
};

通过如上,我们是不是终于摸透了Boss,那就打吧开始,还墨迹啥。

开始

由于initMMAP无法限制游戏角色调几次,所以需要设计一个多次调用的兼容逻辑,防止重新加载出现异常,于是在initMMAP中,调用如下函数

void MMIPC::reloadMmap(const string &path) {
    m_path = path;
    // 重复加载时,文件句柄已经有效,所以先清理下缓存
    if (isFileValid()) {
        doCleanMemoryCache(false);
    }
    // 获取系统默认缓存页大小,也就是boss的怒气槽大小
    default_mmap_size = getPageSize();
    // 如果打开文件失败就直接打印错误日志
    if (!open()) {
        ALOGD("fail to open [%s], %d(%s)", m_path.c_str(), errno, strerror(errno));
    } else {
        // 如果打开成功,这里通过m_fd获取实际文件大小,重新给m_size赋值
        getFileSize(m_fd, m_size);
        // 如果文件大小小于一个缓存页大小,且不等0
        if (m_size < default_mmap_size || (m_size % default_mmap_size != 0)) {
            // 获取比m_size 小 m_size*default_mmap_size分之一的值,这个大小说明:在怒气槽即将满时,给boss留点空间,好逃跑不是
            size_t roundSize = (m_size / default_mmap_size + 1) * default_mmap_size;
            // 看下方函数解释
            truncate(roundSize);
        } else {
            // 如果 m_size 大于一个缓存页大小,重新创建一个boss文件真身,继续干他
            auto ret = mmap();
            if (!ret) {
                // 如果创建替身失败了,boss就怂了
                doCleanMemoryCache(true);
            }
        }
    }
}

// 此函数的目的是为了改变文件大小,也就是boss怒气槽的大小
bool MMIPC::truncate(size_t size) {
    // 文件句柄无效,还打个p,直接收拾装备回家吧,boss跑了
    if (!isFileValid()) {
        return false;
    }
    // 如果怒气槽大小和当前大小一样,直接返回,不需要更新
    if (size == m_size) {
        return true;
    }
    // 如果传进来的大小和旧的不一样,记录下当前怒气槽大小
    auto oldSize = m_size;
    // 直接改变当前怒气槽大小
    m_size = size;
    // 按照m_size开始改变文件m_fd怒气槽的大小
    if (::ftruncate(m_fd, static_cast<off_t>(m_size)) != 0) {
        ALOGE("truncate failed [%s] to size %zu, %s", m_path.c_str(), m_size, strerror(errno));
        m_size = oldSize;
        return false;
    }
    // 改变成功后,判断下旧的是否比现在的大
    if (oldSize > m_size) {
        // 如果比现在的大,改变文件的读写位置,并在合适的位置进行填充0
        if (!zeroFillFile(m_fd, oldSize, m_size - oldSize)) {
            ALOGE("zeroFile fail [%s] to size %zu, %s", m_path.c_str(), m_size,
                  strerror(errno));
            m_size = oldSize;
            return false;
        }
    }
    // 如果发现boss有替身,解除替身的引用,把替身受到的伤害,写入文件里
    if (m_ptr) {
        if (munmap(m_ptr, oldSize) != 0) {
            ALOGE("munmap fail [%s], %s", m_path.c_str(), strerror(errno));
        }
    }
    // 继续创建新的替身,又可以愉快的揍他了
    auto ret = mmap();
    if (!ret) {
        // 如果创建失败,则释放掉
        doCleanMemoryCache(true);
    }
    return ret;
}

boss替身在这里

bool MMIPC::mmap() {
    m_ptr = (char *) ::mmap(m_ptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
    if (m_ptr == MAP_FAILED) {
        ALOGD("mmap failed");
        m_ptr = nullptr;
        return false;
    }
    ALOGD("mmap success");
    return true;
}

boss真身在这里

bool MMIPC::open() {
    //判断文件是否已经打开过
    if (isFileValid()) {
        return true;
    }
    //O_RDWR 可读可写,O_CREAT 如果没有则创建
    m_fd = ::open(m_path.c_str(), O_RDWR | O_CREAT, S_IRWXU);
    if (!isFileValid()) {
        //fail to open
        return false;
    }
    //输出文件句柄的值,可以判断m_fd的值是否==-1,来判断是否获取失败
    ALOGD("open m_fd[%d], %s", m_fd, m_path.c_str());
    return true;
}

void MMIPC::close() {
    if (isFileValid()) {
        // 关闭
        if (::close(m_fd) == 0) {
            m_fd = -1;
        } else {
            //fail to close
        }
    }
}
  • open 通过游戏角色给的路径m_path,我们定位到boss的真身位置,并获取boss文件句柄
  • close 关闭boss文件句柄

真实伤害

void MMIPC::setData(const string &key, const string &value) {
    // 组合伤害,憋大招
    string content = key + ":" + value;
    if (m_position != 0) {
        content = "," + content;
    }
    ALOGD("setData content=%s", content.c_str());
    size_t numberOfBytes = content.length();
    // 如果伤害溢出,则抛异常给上面
    if (m_position + numberOfBytes > m_size) {
        auto msg = "m_position: " + to_string(m_position) + ", numberOfBytes: " +
                   to_string(numberOfBytes) +
                   ", m_size: " + to_string(m_size);
        throw out_of_range(msg);
    }
    // 对替身施加伤害,每次都在上次的m_position之后,累加伤害值
    memcpy(m_ptr + m_position, (void *) content.c_str(), numberOfBytes);
    // 刷新上次的m_position
    m_position += numberOfBytes;
    ALOGD("setData success m_ptr=%s", m_ptr);
}
// 其他跨进程的角色也可以同步到伤害的值
string MMIPC::getData(const string &key, const string &value) {
    return m_ptr;
}

验证

  • App被初始化两次,多进程初始化后都能见到Boss
  • MainActivty,揍了两下
  • OtherProcessActivity,获取MainActivty在主进程中打的伤害,完美跨进程通信

这里面看到两个有价值的信息

  • m_fd = 78 两个进程是同样的虚拟地址,有个疑问,它们是一个虚拟地址么?(答案:不是的,有问题需要自己去探索,去搜吧)
  • 缓存页的大小都是 4096,也就是4kb

复盘

打完boss我们做个小复盘,以便后面打的更快更爽。

做的好的

  • 首先值得肯定是完成了,很多时候完成是第一步,设计过渡应避免
  • 层级划分清晰,各个模块职责分明,功能实现干净利索不拖泥大水

做的不好的

  • Boss真身在Sdcard,被别人上大号,连接上后,一刀抢走怎么办
  • 伤害值,传输过程被别人拦截怎么办,本来你砍了1万点伤害,被别人改成1点
  • 如果跨线程、跨进程都来了伤害,你怎么保证这些伤害都能正常到Boss身上

如何改进

  • 有个办法就是将 真身放到data/data/包名目录下,有人说你为啥不用 mmap参数中 MAP_ANONYMOUS 属性,因为它只能父子进程通信,应用的子进程其实是zygote 的子进程,非你的,我把你当兄弟,你把我当儿子,但data/data下也有风险,如果root手机,你的通信数据就有可能被人篡改
  • 如何防止被篡改呢?建议pb+加密,pb保证传输快,效率高,加密保证不被篡改
  • 如果跨线程可以通过线程锁来实现,如果跨进程怎么办,我看binder实现中用到了MAP_PRIVATE,这个参数有个好处就是,当你往映射内存里写的时候,内核会先copy一份出来再写,这边避免其他进程读的时候不能读,这样一来是不是就好了呢,当然需要验证的(已验证,MAP_PRIVATE方式,拷贝再写入的值,并不会被刷的文件中,也就无法实现共享),多进程的写入,需要写测试用例验证。

以上改进,Doing .......

游戏副本在哪?

说了这么多,游戏副本在哪?欢迎跟我一起来打Boss

github.com/ibaozi-cn/m…

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿