2022年8月

PV-UV-IP 与埋点技术实现

前言

埋点技术在很多流量较大的应用中经常使用到的热门技术,旨在通过在用户进行操作或者请求的时候,记录下这些操作,然后针对记录的操作数量、操作频率才重新设计产品,提高用户体验,而埋点技术在前端的实现相比起后端来说也较为多见,这是因为用户的操作并不一定会带来http请求,而且前端SPA盛行后后端很难监测后用户对页面浏览的情况,所以重担当然就压在前端身上来。

小插曲:其实在大部分的功能性应用中,通过对各功能模块的埋点监测,Banner轮播图的用户访问率非常低,也就是所Banner的存在大部分情况下只是一个好看的装饰品,这一点也引发了大量产品经理的争论。

PV、UV、IP在网站运营和管理中是非常常见的3个Metric,也是产品经理心心念念每天茶不思饭不想的三个宝贝,对于应用开发者来说,也必然会接到相关的开发需求,那么待会儿就会来聊一下这三个小宝贝。


什么是PV、UV、IP

PV(Page View)访问量, 即页面浏览量或点击量,衡量网站用户访问的网页数量;在一定统计周期内用户每打开或刷新一个页面就记录1次,多次打开或刷新同一页面则浏览量累计。 说白了就是统计一下某些页面在一段时间比如一天内被访问了多少次,哪怕是同一个用户访问多次也没关系,说不定这个用户就是特别钟爱这个页面呢?所以重复访问是计算为有效的。


image.pngUV(Unique Visitor)独立访客,统计1天内访问某站点的用户数(以cookie为依据);访问网站的一台电脑客户端为一个访客。可以理解成访问某网站的电脑的数量。网站判断来访电脑的身份是通过来访电脑的cookies实现的。如果更换了IP后但不清除cookies,再访问相同网站,该网站的统计中UV数是不变的。如果用户不保存cookies访问、清除了cookies或者更换设备访问,计数会加1。00:00-24:00内相同的客户端多次访问只计为1个访客。 说白了就是根据用户登陆后所记录的cookie(来源可能是session或者token)来标识一个用户,以统计有多少用户访问应用。



image.png


IP(Internet Protocol)独立IP数,是指1天内多少个独立的IP浏览了页面,即统计不同的IP浏览用户数量。同一IP不管访问了几个页面,独立IP数均为1;不同的IP浏览页面,计数会加1。 IP是基于用户广域网IP地址来区分不同的访问者的,所以,多个用户(多个局域网IP)在同一个路由器(同一个广域网IP)内上网,可能被记录为一个独立IP访问者。如果用户不断更换IP,则有可能被多次统计。 说白了就是根据用户的IP来标识一个用户,以统计有多少用户访问应用。


误差来自于哪里

但是PV、UV和IP的统计方式都是会有误差的。

例如统计PV的时候,来源不明的水军可能会利用脚本不断的重复访问某个网页,使得PV数不正常激增,此时PV > 真实数据。

统计UV的时候,如果有个用户不断清除cookie或者换了很多台设备来访问的话,那么这个用户会被统计多次,此时 UV > 真实数据。 如果多个使用者共用一个账号和同一个设备的时候,此时UV < 真实数据

统计IP的时候,如果用户切换了手机的网络模式(4g -> wifi)此时IP > 真实数据,多个使用者共用同一个设备的时候,此时IP < 真实数据。 ** 也就是误差总是存在的,在数据量较多,统计周期较长并明显存在某些规律的情况下,其实这些误差也是可以忽略的。


技术实现

触发时机

其实无论是PV、UV的统计(IP的统计一般后端可以独立完成,不需要前端的参与),还是埋点技术,说穿了就是要在合适的时机向后端发送一个合适的请求。

什么时机才算是合适的时机呢?这也要具体情况具体分析,比如,PV量统计一般是在路由跳转监听中进行,我们可以在一个全局性的路由钩子中实现。而UV的统计则依赖后端多一些,前端只需要把种好的cookie信息再携带到任意请求的请求体中即可,后端来进行过滤筛选。

埋点技术场景则复杂一些,比如在某些按钮的点击操作中,滚动条的监听事件处理程序中等等。

如何发送请求

PV、UV以及埋点中请求的时机各有不同,发送请求的方式其实也会有选择的空间。

常见的请求的方式例如使用ajax或者fetch来发送GET/POST请求当然可以解决需要,但是这样的方式往往消耗比较大,相应速度也会较慢,好处在于传输到后端的数据可以携带的稍微多一些,请求回来的数据一般都是JSON格式的数据,处理起来也很方便。

但是用Ajax或者fetch的话,很可能带来跨域的问题,因为有的时候记录埋点数据和PV、UV数据的服务器是与应用服务器分离的。

而且其实很多情况下前端在发送此类请求的时候,并不会传递很多信息,往往只是几个简单的query params字段,而且也并不期待服务端的返回信息来使用。

所以真正使用场景较多的实现方式是以img请求的方式来进行的。

Image Beacon技术

上面收到大部分情况下使用img请求的方式是最可行的,那么是为什么呢?具体要怎么实现呢?

首先,利用Image Beacon是不会碰到跨域问题的,浏览器的安全级别限制不针对这个。

而且请求体的体积较小,请求速度较快,网络资源消耗较少。

既然如此,那为什么不选择其他类型文件的请求例如JS文件、CSS文件或者TTF字体类的文件呢?

是因为JS等类型的文件必须要插入到文档树中浏览器才会发送请求,很可能带来渲染的成本而且有可能会阻塞文档树的渲染,但是图片请求则不然,构造图片打点不仅不用插入DOM,只要在js中new出Image对象就能发起请求,而且还没有阻塞问题,在没有js的浏览器环境中也能通过img标签正常打点,这是其他类型的资源请求所做不到的。

说到这里,还有最后一个选择要做,就是应该使用什么格式的Img呢?

首先,1x1像素是最小的合法图片。而且,因为是通过图片打点,所以图片最好是透明的,图片透明只要使用一个二进制位标记图片是透明色即可,不用存储色彩空间数据,可以节约体积。

那么BMP、PNG和GIF格式都支持透明格式,我们要选择哪种呢?答案是GIF格式,据统计,最小的BMP文件需要74个字节,PNG需要67个字节,而合法的GIF,只需要43个字节,体积小请求成本当前就会较低。

最后总结一下为什么此类请求最佳方案是使用GIF格式的1 * 1尺寸透明图片呢?

  1. 能够完成整个 HTTP 请求+响应(尽管不需要响应内容)

  2. 触发 GET 请求之后不需要获取和处理数据、服务器也不需要发送数据

  3. 跨域友好

  4. 执行过程无阻塞

  5. 相比 XMLHttpRequest 对象发送 GET 请求,性能上更好

  6. GIF的最低合法体积最小(最小的BMP文件需要74个字节,PNG需要67个字节,而合法的GIF,只需要43个字节)

最最神奇的是,其实后端都不需要返回前端这个1 * 1的图片,因为前端根本不需要将其渲染到body中,所以后端返回204 -- Not Content都没啥关系,因为我们的目标只有发送请求,请求发送之后,其他的都可以不计较了。


项目中的具体应用

接下来我们来看一下在项目中要怎么去实现PV、UV的统计以及埋点,在这里我以Vue项目为例。

封装beacon动作方法

/**  * @module beacon实现模块 */ import Qs from 'querystring' /**  * @function 具体的beacon动作  * @param {String} apiUrl 发送的接口请求  * @param {Object} params 具体发送的数据  * @example beaconAction('/api/recored', { url: '...' }).then((res) => {}).catch((error) => {})  */ export const beaconAction = (apiUrl, params) => {   /** 如果参数不是字符串则转换为query-string  */   let _params = typeof params === 'string' ? params : Qs.stringify(params)   /** 创建Image对象来发送请求  */   let img = new Image(1, 1)   let src = `${apiUrl}?${_params}`   img.src = src   /** 其实并不需要将此图片append到body中,请求此时已经发送,目的已经达成了  */   /** 利用load和error事件来监听动作的完成,返回Promise便于操作  */   return new Promise((resolve, reject) => {     img.onload = function () {       resolve({ code: 200, data: 'success!' })     }     img.onerror = function (e) {       reject(new Error(e.error))     }   }) } 复制代码

在路由监听中记录PV、UV

/**  * PV/UV记录  */ router.afterEach((to, from) => {   const path = to.path   /** 如果开启了登陆权限验证 */   if (process.env.AUTH_ENABLED) {     /** 除了登陆界面,其他路由界面都请求记录 */     if (to.path !== '/login') {       pathBeaconAction(path)     }   } }) 复制代码

Vue中实现埋点

在vue中推荐大家使用自定义指令来实现埋点操作,封装好合适的自定义指令后,就可以在需要埋点的dom或者组件上通过非常轻松的指令设置来实现埋点效果。

在这里推荐大家使用一个第三方指令** v-track** 来完成需求,大家可以移步官网来查看详细的使用说明。

使用方式:

<!-- 页面行为埋点(track-view为v-track全局注册的组件) --> <track-view v-track:18015></track-view> <track-view v-track:18015.watch="{ rest }"></track-view> <track-view v-track:18015.watch.delay="{ rest }"></track-view> <track-view v-if="rest" v-track:18015></track-view>   <!-- 事件行为埋点(DOM) --> <div v-track:18015.click="handleClick"></div> <div v-track:18015.click="{ handleClick, item, index }"></div> <div v-track:18015.click.async="{ handleSearch, rest }"></div> <div v-track:18015.click.delay="handleClick"></div>   <!-- 事件行为埋点(组件) --> <cmp v-track:18015.click="handleClick"></cmp> <cmp v-track:18015.[自定义事件名]="handleSearch"></cmp> <cmp v-track:18015.[自定义事件名].delay="handleSearch"></cmp> <cmp v-track:18015.[自定义事件名].async="{ handleSearch, rest }"></cmp>   <!-- 区域展现埋点(block 可以是 DOM 或者组件) --> <block v-track:18015.show></block> <block v-track:18015.show.once></block> <block v-track:18015.show.custom="{ ref: 'scroll' }"></block> <block v-track:18015.show.custom.once="{ ref: 'scroll' }"></block> 复制代码

修饰符说明:

  • .click 表示事件行为的埋点

  • .[custom-event] 表示自定义事件行为的埋点

  • .native 表示监听组件原生click事件行为的埋点

  • .watch 表示页面异步行为的埋点

  • .async 配合.click指令,表示异步事件行为的埋点

  • .delay 表示埋点是否延迟执行,默认先执行埋点再执行回调

  • .show 表示区域曝光埋点

  • .once 配合.show指令,只执行一次埋点

  • .custom 配合.show指令,表示使用自定义scroll事件


后语

埋点和PVUV的实现方案实在是太多了,而每一种方案都会引起很多人的讨论,在此希望诸君也能有自己的想法,欢迎来讨论。


作者:半盏屠苏
链接:https://juejin.cn/post/6844903955642728456
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


一直以来,C++中基于值语义的拷贝和赋值严重影响了程序性能。尤其是对于资源密集型对象,如果进行大量的拷贝,势必会对程序性能造成很大的影响。为了尽可能的减小因为对象拷贝对程序的影响,开发人员使出了万般招式:尽可能的使用指针、引用。而编译器也没闲着,通过使用RVO、NRVO以及复制省略技术,来减小拷贝次数来提升代码的运行效率。

但是,对于开发人员来说,使用指针和引用不能概括所有的场景,也就是说仍然存在拷贝赋值等行为;对于编译器来说,而对于RVO、NRVO等编译器行为的优化需要满足特定的条件(具体可以参考文章编译器之返回值优化)。为了解决上述问题,自C++11起,引入了移动语义,更进一步对程序性能进行优化 。

C++11新标准重新定义了lvalue和rvalue,并允许函数依照这两种不同的类型进行重载。通过对于右值(rvalue)的重新定义,语言实现了移动语义(move semantics)和完美转发(perfect forwarding),通过这种方法,C++实现了在保留原有的语法并不改动已存在的代码的基础上提升代码性能的目的。

本文的主要内容如下图所示:

图片

值语义

值语义(value semantics)指目标对象由源对象拷贝生成,且生成后与源对象完全无关,彼此独立存在,改变互不影响,就像int类型互相拷贝一样。C++的内置类型(bool/int/double/char)都是值语义,标准库里的complex<> 、pair<>、vector<>、map<>、string等等类型也都是值语意,拷贝之后就与原对象脱离关系。

C++中基于值语义的拷贝构造和赋值拷贝,会招致对资源密集型对象不必要拷贝,大量的拷贝很可能成为程序的性能瓶颈。

首先,我们看一段例子:

BigObj fun(BigObj obj) {
  BigObj o;
  // do sth
  return o;
}

int main() {
  fun(BigObj());
  return 0;
}

在上述代码中,我们定义了一个函数fun()其参数是一个BigObj对象,当调用fun()函数时候,会通过调用BigObj的拷贝构造函数,将obj变量传递给fun()的参数。

编译器知道何时调用拷贝构造函数或者赋值运算符进行值传递。如果涉及到底层资源,比如内存、socket等,开发人在定义类的时候,需要实现自己的拷贝构造和赋值运算符以实现深拷贝。然而拷贝的代价很大,当我们使用STL容器的时候,都会涉及到大量的拷贝操作,而这些会浪费CPU和内存等资源。

正如上述代码中所示的那样,当我们将一个临时变量(BigObj(),也称为右值)传递给一个函数的时候,就会导致拷贝操作,那么我们该如何避免此种拷贝行为呢?这就是我们本文的主题:移动语义

左值、右值

关于左值、右值,我们在之前的文章中已经有过详细的分享,有兴趣的同学可以移步【Modern C++】深入理解左值、右值,在本节,我们简单介绍下左值和右值的概念,以方便理解下面的内容。

左值(lvalue,left value),顾名思义就是赋值符号左边的值。准确来说,左值是表达式结束(不一定是赋值表达式)后依然存在的对象。

可以将左值看作是一个关联了名称的内存位置,允许程序的其他部分来访问它。在这里,我们将 "名称" 解释为任何可用于访问内存位置的表达式。所以,如果 arr 是一个数组,那么 arr[1] 和 *(arr+1) 都将被视为相同内存位置的“名称”。

左值具有以下特征:

  • 可通过取地址运算符获取其地址
  • 可修改的左值可用作内建赋值和内建符合赋值运算符的左操作数
  • 可以用来初始化左值引用(后面有讲)

C++11将右值分为纯右值将亡值两种。纯右值就是C++98标准中右值的概念,如非引用返回的函数返回的临时变量值;一些运算表达式,如1+2产生的临时变量;不跟对象关联的字面量值,如2,'c',true,"hello";这些值都不能够被取地址。而将亡值则是C++11新增的和右值引用相关的表达式,这样的表达式通常是将要移动的对象、T&&函数返回值、std::move()函数的返回值等。

左值引用、右值引用

在明确了左值和右值的概念之后,我们将在本节简单介绍下左值引用和右值引用。

按照概念,对左值的引用称为左值引用,而对右值的引用称为右值引用。既然有了左值引用和右值引用,那么在C++11之前,我们通常所说的引用又是什么呢?其实就是左值引用,比如:

int a = 1;
int &b = a;

在C++11之前,我们通过会说b是对a的一个引用(当然,在C++11及以后也可以这么说,大家潜移默化的认识就是引用==左值引用),但是在C++11中,更为精确的说法是b是一个左值引用。

在C++11中,为了区分左值引用,右值引用用&&来表示,如下:

int &&a = 1// a是一个左值引用
int b = 1;
int &&c = b; // 错误,右值引用不能绑定左值

跟左值引用一样,右值引用不会发生拷贝,并且右值引用等号右边必须是右值,如果是左值则会编译出错,当然这里也可以进行强制转换,这将在后面提到。

在这里,有一个大家都经常容易犯的一个错误,就是定右值的右值引用,其变量本身是个左值。为了便于理解,代码如下:

int fun(int &a) {
  std::cout << "in fun(int &)" << std::endl;
}

int fun(int &&a) {
  std::cout << "in fun(int &)" << std::endl;
}

int main() {
  int a = 1;
  int &&b = 1;
  
  fun(b);
  
  return 0;
}

代码输出如下:

in fun(int &)

左值引用和右值引用的规则如下:

  • 左值引用,使用T&,只能绑定左值
  • 右值引用,使用T&&,只能绑定右值
  • 常量左值,使用const T&,既可以绑定左值,又可以绑定右值,但是不能对其进行修改
  • 具名右值引用,编译器会认为是个左值
  • 编译器的优化需要满足特定条件,不能过度依赖

好了,截止到目前,相信你对左值引用和右值引用的概念有了初步的认识,那么,现在我们介绍下为什么要有右值引用呢?我们看下述代码:

BigObj fun() {
  return BigObj();
}
BigObj obj = fun(); // C++11以前
BigObj &&obj = fun(); // C++11

上述代码中,在C++11之前,我们只能通过编译器优化(N)RVO的方式来提升性能,如果不满足编译器的优化条件,则只能通过拷贝等方式进行操作。自C++11引入右值引用后,对于不满足(N)RVO条件,也可以通过避免拷贝延长临时变量的生命周期,进而达到优化的目的。

但是仅仅使用右值引用还不足以完全达到优化目的,毕竟右值引用只能绑定右值。那么,对于左值,我们又该如何优化呢?是否可以通过左值转成右值,然后进行优化呢?等等

为了解决上述问题,标准引入了移动语义。通移动语义,可以在必要的时候避免拷贝;标准提供了move()函数,可以将左值转换成右值。接下来,就开始我们本文的重点-移动语义。

移动语义

移动语义是Howard Hinnant在2002年向C++标准委员会提议的,引用其在移动语义提案上的一句话:

移动语义不是试图取代复制语义,也不是以任何方式破坏它。相反,该提议旨在增强复制语义

对于刚刚接触移动语义的开发人员来说,很难理解为什么有了值语义还需要有移动语义。我们可以想象一下,有一辆汽车,在内置发动机的情况下运行平稳,有一天,在这辆车上安装了一个额外的V8发动机。当有足够燃料的时候,V8发动机就能进行加速。所以,汽车是值语义,而V8引擎则是移动语义。在车上安装引擎不需要一辆新车,它仍然是同一辆车,就像移动语义不会放弃值语义一样。所以,如果可以,使用移动语义,否则使用值语义,换句话说就是,如果燃料充足,则使用V8引擎,否则使用原始默认引擎。

好了,截止到现在,我们对移动语义有一个感官上的认识,它属于一种优化,或者说属于锦上添花。再次引用Howard Hinnant在移动语义提案上的一句话:

移动语义主要是性能优化:将昂贵的对象从内存中的一个地址移动到另外一个地址的能力,同时窃取源资源以便以最小的代价构建目标

在C++11之前,当进行值传递时,编译器会隐式调用拷贝构造函数;自C++11起,通过右值引用来避免由于拷贝调用而导致的性能损失。

右值引用的主要用途是创建移动构造函数和移动赋值运算符。移动构造函数和拷贝构造函数一样,将对象的实例作为其参数,并从原始对象创建一个新的实例。但是,移动构造函数可以避免内存重新分配,这是因为移动构造函数的参数是一个右值引用,也可以说是一个临时对象,而临时对象在调用之后就被销毁不再被使用,因此,在移动构造函数中对参数进行移动而不是拷贝。换句话说,右值引用移动语义允许我们在使用临时对象时避免不必要的拷贝。

移动语义通过移动构造函数移动赋值操作符实现,其与拷贝构造函数类似,区别如下:

  • 参数的符号必须为右值引用符号,即为&&
  • 参数不可以是常量,因为函数内需要修改参数的值
  • 参数的成员转移后需要修改(如改为nullptr),避免临时对象的析构函数将资源释放掉

为了方便我们理解,下面代码包含了完整的移动构造和移动运算符,如下:

class BigObj {
public:
    explicit BigObj(size_t length)
        : length_(length)data_(new int[length]) 
{
    }

    // Destructor.
    ~BigObj() {
     if (data_ != NULL) {
       delete[] data_;
        length_ = 0;
     }
    }

    // 拷贝构造函数
    BigObj(const BigObj& other)
     : length_(other.length_), data(new int[other.length_]) {
   std::copy(other.mData, other.mData + mLength, mData);
    }

    // 赋值运算符
    BigObj& operator=(const BigObj& other) {
   if (this != &other;) {
      delete[] data_;  
      length_ = other.length_;
        data_ = new int[length_];
        std::copy(other.data_, other.data_ + length_, data_);
   }
   return *this;
    }

    // 移动构造函数
    BigObj(BigObj&& other) : data_(nullptr), length_(0) {
        data_ = other.data_;
        length_ = other.length_;

        other.data_ = nullptr;
        other.length_ = 0;
    }

    // 移动赋值运算符
    BigObj& operator=(BigObj&& other) {  
      if (this != &other;) {
          delete[] data_;

          data_ = other.data_;
          length_ = other.length_;

          other.data_ = NULL;
          other.length_ = 0;
       }
       return *this;
    }

private:
    size_t length_;
    int* data_;
};

int main() {
   std::vector<BigObj> v;
   v.push_back(BigObj(25));
   v.push_back(BigObj(75));

   v.insert(v.begin() + 1, BigObj(50));
   return 0;
}

移动构造

移动构造函数的定义如下:

BigObj(BigObj&& other) : data_(nullptr), length_(0) {
        data_ = other.data_;
        length_ = other.length_;

        other.data_ = nullptr;
        other.length_ = 0;
    }

从上述代码可以看出,它不分配任何新资源,也不会复制其它资源:other中的内存被移动到新成员后,other中原有的内容则消失了。换句话说,它窃取了other的资源,然后将other设置为其默认构造的状态。在移动构造函数中,最最关键的一点是,它没有额外的资源分配,仅仅是将其它对象的资源进行了移动,占为己用。

在此,我们假设data_很大,包含了数百万个元素。如果使用原来拷贝构造函数的话,就需要将该数百万元素挨个进行复制,性能可想而知。而如果使用该移动构造函数,因为不涉及到新资源的创建,不仅可以节省很多资源,而且性能也有很大的提升。

移动赋值运算符

代码如下:

BigObj& operator=(const BigObj& other) {
   if (this != &other;) {
      delete[] data_;  
      length_ = other.length_;
        data_ = new int[length_];
        std::copy(other.data_, other.data_ + length_, data_);
   }
   return *this;
    }

移动赋值运算符的写法类似于拷贝赋值运算符,所不同点在于:移动赋值预算法会破坏被操作的对象(上述代码中的参数other)。

移动赋值运算符的操作步骤如下:

  1. 释放当前拥有的资源
  2. 窃取他人资源
  3. 将他人资源设置为默认状态
  4. 返回*this

在定义移动赋值运算符的时候,需要进行判断,即被移动的对象是否跟目标对象一致,如果一致,则会出问题,如下代码:

data = std::move(data);

在上述代码中,源和目标是同一个对象,这可能会导致一个严重的问题:它最终可能会释放它试图移动的资源。为了避免此问题,我们需要通过判断来进行,比如可以如下操作:

if (this == &other) {
  return *this
}

生成时机

众所周知,在C++中有四个特殊的成员函数:默认构造函数、析构函数,拷贝构造函数,拷贝赋值运算符。之所以称之为特殊的成员函数,这是因为如何开发人员没有定义这四个成员函数,那么编译器则在满足某些特定条件(仅在需要的时候才生成,比如某个代码使用它们但是它们没有在类中明确声明)下,自动生成。这些由编译器生成的特殊成员函数是public且inline。

自C++11起,引入了另外两只特殊的成员函数:移动构造函数和移动赋值运算符。如果开发人员没有显示定义移动构造函数和移动赋值运算符,那么编译器也会生成默认。与其他四个特殊成员函数不同,编译器生成默认的移动构造函数和移动赋值运算符需要,满足以下条件:

  • 如果一个类定义了自己的拷贝构造函数,拷贝赋值运算符或者析构函数(这三者之一,表示程序员要自己处理对象的复制或释放问题),编译器就不会为它生成默认的移动构造函数或者移动赋值运算符,这样做的目的是防止编译器生成的默认移动构造函数或者移动赋值运算符不是开发人员想要的
  • 如果类中没有提供移动构造函数和移动赋值运算符,且编译器不会生成默认的,那么我们在代码中通过std::move()调用的移动构造或者移动赋值的行为将被转换为调用拷贝构造或者赋值运算符
  • 只有一个类没有显示定义拷贝构造函数、赋值运算符以及析构函数,且类的每个非静态成员都可以移动时,编译器才会生成默认的移动构造函数或者移动赋值运算符
  • 如果显式声明了移动构造函数或移动赋值运算符,则拷贝构造函数和拷贝赋值运算符将被 隐式删除(因此程开发人员必须在需要时实现拷贝构造函数和拷贝赋值运算符)

与拷贝操作一样,如果开发人员定义了移动操作,那么编译器就不会生成默认的移动操作,但是编译器生成移动操作的行为和生成拷贝操作的行为有些许不同,如下:

  • 两个拷贝操作是独立的:声明一个不会限制编译器生成另一个。所以如果你声明一个拷贝构造函数,但是没有声明拷贝赋值运算符,如果写的代码用到了拷贝赋值,编译器会帮助你生成拷贝赋值运算符。同样的,如果你声明拷贝赋值运算符但是没有拷贝构造函数,代码用到拷贝构造函数时编译器就会生成它。上述规则在C++98和C++11中都成立。
  • 两个移动操作不是相互独立的。如果你声明了其中一个,编译器就不再生成另一个。如果你给类声明了,比如,一个移动构造函数,就表明对于移动操作应怎样实现,与编译器应生成的默认逐成员移动有些区别。如果逐成员移动构造有些问题,那么逐成员移动赋值同样也可能有问题。所以声明移动构造函数阻止编译器生成移动赋值运算符,声明移动赋值运算符同样阻止编译器生成移动构造函数。

类型转换-move()函数

在前面的文章中,我们提到,如果需要调用移动构造函数和移动赋值运算符,就需要用到右值。那么,对于一个左值,又如何使用移动语义呢?自C++11起,标准库提供了一个函数move()用于将左值转换成右值。

首先,我们看下cppreference中对move语义的定义:

std::move is used to indicate that an object t may be "moved from", i.e. allowing the efficient transfer of resources from t to another object.

In particular, std::move produces an xvalue expression that identifies its argument t. It is exactly equivalent to a static_cast to an rvalue reference type.

从上述描述,我们可以理解为std::move()并没有移动任何东西,它只是进行类型转换而已,真正进行资源转移的是开发人员实现的移动操作

该函数在STL中定义如下:

 template<typename _Tp>
    constexpr typename std::remove_reference<_Tp>::type&&
    move(_Tp&& __t) noexcept
    
return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

从上面定义可以看出,std::move()并不是什么黑魔法,而只是进行了简单的类型转换:

  • 如果传递的是左值,则推导为左值引用,然后由static_cast转换为右值引用
  • 如果传递的是右值,则推导为右值引用,然后static_cast转换为右值引用

使用move之后,就意味着两点:

  • 原对象不再被使用,如果对其使用会造成不可预知的后果
  • 所有权转移,资源的所有权被转移给新的对象

使用

在某些情况下,编译器会尝试隐式移动,这意味着您不必使用std::move()。只有当一个非常量的可移动对象被传递、返回或赋值,并且即将被自动销毁时,才会发生这种情况。

自c++11起,开始支持右值引用。标准库中很多容器都支持移动语义,以std::vector<>为例,**vector::push_back()**定义了两个重载版本,一个像以前一样将const T&用于左值参数,另一个将T&&类型的参数用于右值参数。如下代码:

int main() {
  std::vector<BigObj> v;
  v.push_back(BigObj(10));
  v.push_back(BigObj(20));
  
  return 0;
}

两个push_back()调用都将解析为push_back(T&&),因为它们的参数是右值。push_back(T&&)使用BigObj的移动构造函数将资源从参数移动到vector的内部BigObj对象中。而在C++11之前,上述代码则生成参数的拷贝,然后调用BigObj的拷贝构造函数。

如果参数是左值,则将调用push_back(T&):

int main() {
  std::vector<BigObj> v;
  BigObj obj(10);
  v.push_back(obj); // 此处调用push_back(T&)
  
  return 0;
}

对于左值对象,如果我们想要避免拷贝操作,则可以使用标准库提供的move()函数来实现(前提是类定义中实现了移动语义),代码如下:

int main() {
  std::vector<BigObj> v;
  BigObj obj(10);
  v.push_back(std::move(obj)); // 此处调用push_back(T&&)
  
  return 0;
}

我们再看一个常用的函数swap(),在使用移动构造之前,我们定义如下:

template<class T>
void swap(T &a, T &b) 
{
    T temp = a; // 调用拷贝构造函数
    a = b; // 调用operator=
    b = temp; // 调用operator=
}

如果T是简单类型,则上述转换没有问题。但如果T是含有指针的复合数据类型,则上述转换中会调用一次复制构造函数,两次赋值运算符重载。

图片

而如果使用move()函数后,则代码如下:

template<class T>
void swap(T &a, T &b) 
{
    T temp = std::move(a);
    a = std::move(b);
    b = std::move(temp);
}

与传统的swap实现相比,使用move()函数的swap()版本减少了拷贝等操作。如果T是可移动的,那么整个操作将非常高效。如果它是不可移动的,那么它和普通的swap函数一样,调用拷贝和赋值操作,不会出错,且是安全可靠的。

图片

经验之谈

对int等基础类型进行move()操作,不会改变其原值

对于所有的基础类型-int、double、指针以及其它类型,它们本身不支持移动操作(也可以说本身没有实现移动语义,毕竟不属于我们通常理解的对象嘛),所以,对于这些基础类型进行move()操作,最终还是会调用拷贝行为,代码如下:

int main()
{
  int a = 1;
  int &&b = std::move(a);

  std::cout << "a = " << a << std::endl;
  std::cout << "b = " << b << std::endl;

  return 0;
}

最终结果输出如下:

a = 1
b = 1

move构造或者赋值函数中,请将原对象恢复默认值

我们看如下代码:

class BigObj {
public:
    explicit BigObj(size_t length)
        : length_(length)data_(new int[length]) 
{
    }

    // Destructor.
    ~BigObj() {
     if (data_ != NULL) {
       delete[] data_;
        length_ = 0;
     }
    }

    // 拷贝构造函数
    BigObj(const BigObj& other) = default;

    // 赋值运算符
    BigObj& operator=(const BigObj& other) = default;

    // 移动构造函数
    BigObj(BigObj&& other) : data_(nullptr), length_(0) {
        data_ = other.data_;
        length_ = other.length_;
    }

private:
    size_t length_;
    int* data_;
};

int main() {
   BigObj obj(1000);
   BigObj o;
   {
    o = std::move(obj);
   }
   
   // use obj;
   return 0;
}

在上述代码中,调用移动构造函数后,没有将原对象回复默认值,导致目标对象和原对象的底层资源(data_)执行同一个内存块,这样就导致退出main()函数的时候,原对象和目标对象均调用析构函数释放同一个内存块,进而导致程序崩溃。

不要在函数中使用std::move()进行返回

我们仍然以Obj进行举例,代码如下:

Obj fun() {
  Obj obj;
  return std::move(obj);
}

int main() {
  Obj o1 = fun();
  return 0;
}

程序输出:

in Obj()  0x7ffe600d79e0
in Obj(const Obj &&obj)
in ~Obj() 0x7ffe600d79e0

如果把fun()函数中的std::move(obj)换成return obj,则输出如下:

in Obj()  0x7ffcfefaa750

通过上述示例的输出,是不是有点超出我们的预期

前言


在 Android 系统上, Camera 输出的图像一般为 NV21(YUV420SP 系列) 格式, 当我们想进行录像处理时, 会面临两个问题


问题 1

图像的旋转问题

  • 后置镜头: 需要旋转 90°

  • 前置镜头: 需要旋转 270° 然后再进行镜像处理

问题 2

处理好镜头的旋转后, 当我们尝试使用 MediaCodec 进行 H.264 的硬编时, 便会发现偏色的问题


这是因为 MediaCodec 的 COLOR_FormatYUV420SemiPlanar 格式为 NV12, 并非是 NV21, 虽然都是 YUV420SP 系列, 但他们的排列不同, 都是先存储 Y 的数据, NV21 是 vu 交替存储, NV12 是 uv 交替存储

-NV21: yyyy yyyy vu vu
-NV12: yyyy yyyy uv uv

为了解决这个问题, 对于这个问题网上有很多的解决思路, 我们可以在 Java 层使用进行数据操作, 不过经过测试之后发现, 在 Samsung S7 Edge 上, 录制 1080p。


  • 旋转与镜像: 20ms

  • NV21 转 NV12: 16ms


消耗时长约为 40ms, 这也仅仅是勉强能够进行 25 帧的录制, 在使用 opencv 进行人脸识别或滤镜处理时, 能够感觉到明显的卡顿感。


libyuv 便是 google 为了解决移动端 NV21 数据处理不便所提供的开源库, 它提供了旋转, 裁剪, 镜像, 缩放等功能。


接下来看看 libyuv 的编译与使用。

一. 环境

操作系统

MacOS Mojave version 10.14.5


Libyuv

https://chromium.googlesource.com/libyuv/libyuv/

git clone https://chromium.googlesource.com/libyuv/libyuv

图片libyuv 源码

NDK 版本

NDK16


cmake 版本

➜  ~ cmake -version
cmake version 3.14.5

二. 编译脚本

从 libyuv 的源码中, 可以看到 libyuv 已经提供了 CMakeLists.txt, 因此我们可以直接通过 cmake 生成 Makefile, 然后通过 make 对 Makefile 进行编译

ARCH=arm
ANDROID_ARCH_ABI=armeabi-v7a
NDK_PATH=/Users/sharrychoo/Library/Android/ndk/android-ndk-r16b
PREFIX=`pwd`/android/${ARCH}/${CPU}

# cmake 传参
cmake -G"Unix Makefiles" \
-DANDROID_NDK=${NDK_PATH} \
   -DCMAKE_TOOLCHAIN_FILE=${NDK_PATH}/build/cmake/android.toolchain.cmake \
   -DANDROID_ABI=${ANDROID_ARCH_ABI} \
   -DANDROID_NATIVE_API_LEVE=16 \
   -DCMAKE_INSTALL_PREFIX=${PREFIX} \
-DANDROID_ARM_NEON=TRUE \
   ..
   
# 生成动态库
make
make install

图片编译结果

输出的 so 库

图片so 库

三. 代码编写

我们将 so 库和头文件拷贝到 AS 中, 便可以进行代码的编写了, 这里编写一个 Libyuv 的工具类, 方便后续使用


一) Java 代码


这里以 NV21 转 I420 为例

/**
* 处理 YUV 的工具类
*
* @author Sharry <a href="sharrychoochn@gmail.com">Contact me.</a>
* @version 1.0
* @since 2019-07-23
*/

public class LibyuvUtil {

   static {
       System.loadLibrary("smedia-camera");
   }

   /**
    * 将 NV21 转 I420
    */

   public static native void convertNV21ToI420(byte[] src, byte[] dst, int width, int height);
   
   ......
}

二) native 实现

这里以将 NV21 转 I420 为例

namespace libyuv_util {

   void convertI420ToNV12(JNIEnv *env, jclass, jbyteArray i420_src, jbyteArray nv12_dst, int width,
                          int height)
{
       jbyte *src = env->GetByteArrayElements(i420_src, NULL);
       jbyte *dst = env->GetByteArrayElements(nv12_dst, NULL);
       // 执行转换 I420 -> NV12 的转换
       LibyuvUtil::I420ToNV12(src, dst, width, height);
       // 释放资源
       env->ReleaseByteArrayElements(i420_src, src, 0);
       env->ReleaseByteArrayElements(nv12_dst, dst, 0);
   }
   
}

void LibyuvUtil::NV21ToI420(jbyte *src, jbyte *dst, int width, int height){
   // NV21 参数
   jint src_y_size = width * height;
   jbyte *src_y = src;
   jbyte *src_vu = src + src_y_size;
   // I420 参数
   jint dst_y_size = width * height;
   jint dst_u_size = dst_y_size >> 2;
   jbyte *dst_y = dst;
   jbyte *dst_u = dst + dst_y_size;
   jbyte *dst_v = dst + dst_y_size + dst_u_size;
   /**
   * <pre>
   * int NV21ToI420(const uint8_t* src_y,
   *          int src_stride_y,
   *          const uint8_t* src_vu,
   *          int src_stride_vu,
   *          uint8_t* dst_y,
   *          int dst_stride_y,
   *          uint8_t* dst_u,
   *          int dst_stride_u,
   *          uint8_t* dst_v,
   *          int dst_stride_v,
   *          int width,
   *          int height);
   * </pre>
   * <p>
   * stride 为颜色分量的跨距: 它描述一行像素中, 该颜色分量所占的 byte 数目, YUV 每个通道均为 1byte(8bit)
   * <p>
   * stride_y: Y 是最全的, 一行中有 width 个像素, 也就有 width 个 Y
   * stride_u: YUV420 的采样为 Y:U:V = 4:1:1, 从整体的存储来看, 一个 Y 分量的数目为 U/V 的四倍
   * 但从一行上来看, width 个 Y, 它会用到 width/2 个 U
   * stride_v: 同 stride_u 的分析方式
   */

   libyuv::NV21ToI420(
           (uint8_t *) src_y, width,
           (uint8_t *) src_vu, width,
           (uint8_t *) dst_y, width,
           (uint8_t *) dst_u, width >> 1,
           (uint8_t *) dst_v, width >> 1,
           width, height
   );
}

可以看到方法的调用也非常的简单, 只需要传入相关参数即可, 其中有个非常重要的参数, stride 跨距, 它描述一行像素中, 该颜色分量所占的 byte 数目

  • YUV420 系列

    • Y: 跨距为 width

    • U: 跨距为 width/2

    • V: 跨距为 width/2

    • Y: 跨距为 width

    • VU: 跨距为 width

    • NV21

    • I420P(YU12):

  • ABGR: 跨距为 4 *width

总结

通过 libyuv 进行旋转镜像转码等操作, 其时长如下


  • 旋转镜像: 5~8ms

  • NV21 转 NV12: 0~3ms

可以看到比起 java 代码, 几乎快了 3 倍, 这已经能够满足流畅录制的需求了


笔者将常用的 YUV 操作整理成了demo 点击查看, 如有需要可以将代码直接拷走使用:

https://github.com/SharryChoo/LibyuvSample





不知道你有没有这样的困惑:

在人际交往中,每次需要别人帮忙的时候,都要纠结很长时间;

怕麻烦别人,也害怕提要求会被拒绝;

甚至,每当接受别人的帮助时,都会觉得愧疚。

但就如武志红老师所说:“很多人怕麻烦别人,但是,不麻烦彼此,关系也就无从建立。


不想麻烦别人
来自于你内在无价值的感受


生活当中,我们总是免不了跟人接触、交往。

和亲戚、爱人、朋友,在共同分担的时候感受到支持和滋养,在互相帮助中成长,也在彼此“麻烦”中建立更深的关系。

你付出,我接受,然后有一天,你接受,我付出,一种动态中平衡,令关系向前。

而不是你害怕付出,我也不敢接受,或者我不付出,你也不接受。在这样静止而隔离的关系状态里,无论是我们自身还是关系,都没有办法继续成长。

这让我想起了自己刚开始成为一名讲师和咨询师的时光。

大概是在十八年前,当时因为职业生涯刚刚起步,只要有相关的工作机会,我都非常珍惜。

有一次,我到广州的一家幼儿园给家长们做一个两小时的分享,我做了很多准备,分享的效果也相当不错,分享结束之后,主办方送了一束花给我。

面对这束送到我眼前的花,我诚惶诚恐,有一种不好意思接过来的感觉,内心简直比讲课还要紧张。

后来我尝试去自我觉察,为什么我在别人送给我花的时候,会这么慌张呢?

我发现,一方面,我想要谦逊,不想过多地麻烦别人,另外一方面,我也发现,在表面的这一份谦逊底下,和害怕麻烦别人的低下,我内在有一部分无价值感、低自尊感的感受。

当时的我潜意识中有种声音和想法是这样的:

我所付出的时间,所讲的内容,似乎都不重要,别人愿意来听,比我讲什么更重要,居然还要送一束花给我,给我的实在是太多了。

所以,当我接受别人的馈赠,或者帮助时,我的第一反应就是受之有愧和手足无措,内心充斥着这样的想法:

我会不会麻烦别人了呢?

我值得拥有这些帮助和肯定吗?



失衡的情感天平


我们提出要求的信心,和我们有多坦然接受别人的帮助,是成正比的。

为什么这么说呢?

因为我们每一个人的内在都有着一个情感天平。

当我决定接受你的帮助之后,事实上,我的内心已经准备好,我有信心、有能力同样地付出给你。

这时候,我们内在情感的天平是趋向平衡的,在接受和付出之间,相对平衡,内心没有任何的愧疚感。

但是,假如当我接受了你的帮助,觉得自己没有能力、没有信心,可以在某个时候,同样去付出和回报时,內在的情感天平就失衡了。

我感觉到愧疚,没有价值感。

内在情感天平的失衡,让我们不敢去麻烦别人,因为我不知道如何面对内心愧疚的感觉,也就不敢去接受别人的付出和帮助。

渐渐地,这会让我们的自我界限不断收紧,心也渐渐关闭,紧紧地锁上内在脆弱、无价值的部分。然后我们也不会付出,也不会接受了。

接受和付出,是一个动态的平衡;

求助和接受帮助,同样是一种动态的平衡。

当我们可以去提出和请求他人的帮助,我们的内心,如果已经准备好有一天,也去付出和回馈对方帮助的时候,我们就可以有勇气和力量去开口求助了。

所以,当你总是害怕麻烦别人,而无法大方地去提出要求或是求助的时候,去觉察一下——

内在的情感天平是否因为低价值感和低资格感而失衡了,导致你无法接受他人的帮助,也无法为他人付出。



练习在接受和付出之间平衡


接下来,我会带领一个催眠对话的练习,邀请你去想象在你生命中最难为情,不敢开口求助的一个人。

也许是你的父母,也许是你的朋友,也许是你的爱人,也许是同事,想象TA就站在你眼前;

然后通过正向催眠的对话,去转化隐藏在我们内心的低自尊感、负疚感等情绪,让内在的情感天平处在一个平衡而有力量的状态。

好,现在你可以找到一个安静的地方,让自己安顿下来,做一两个深呼吸,慢慢地回到内在。

现在,我邀请你去感受,在你生命中最难为情,最不敢开口求助的人是谁呢?

是父母,或者某个朋友,或者是你的伴侣,公司里的某个同事……

无论是谁,想象TA站在你的前面,TA距离你多远呢?在什么样的位置呢?

当你面对TA,你会讲出什么样的要求呢?

当你去思考要怎么说,或者应不应该说,或者是想象对方会怎样回应你,答不答应你的请求时,做一个呼吸,去觉察,你这个时候的内在感受是怎样的呢?

也许是负疚感吗,也许是软弱感,也许是害怕被拒绝……

无论那是什么,做一个呼吸,给这些情绪、感受一个位置,对TA们说,欢迎,欢迎,欢迎……

Ta们的到来是有意义的,欢迎,欢迎,给负疚感、软弱感,或者是害怕被拒绝的感受一个位置。

然后,做一个呼吸,继续看着面前这个人,看着TA的眼睛,说:

“我今天对你的请求,我知道你不是必须要这么做,但无论你答不答应我,我都已经很感谢。

在我生命中有一段关系,在我需要帮助的时候,我想起了你,所以我很感恩,在我的身边,有这样的朋友/家人/伙伴······”

做一个呼吸,带着这种感恩,在我的生命中,当我需要帮助,我还可以有求助的人,我不是独自一人,孤立无援······

从这个感觉里,继续看着这个人,对TA说:

“如果你愿意帮助我,这对我来说,真的是生命中一份很重要的礼物,不是关于你帮助我的事情价值有多大,而是你这份帮助我的心,远远超越任何物质的价值,或是这件事情本身······

所以,我已经准备好,当有一天,你需要帮助的时候,我也可以像你帮助我一样,去帮助你,回馈给你······”

做一个呼吸,感受一下,当你在内在和对面的这个人,这样去互动,去回应,会有怎样的感觉?

假如有一天,去到一个现实的情景,你真的需要求助,带着这样一份完整感、勇气和力量,去说出你的请求的时候,那会有什么不同呢?

你是不是更加有求助的能力了呢?

去欣赏这一个学习的过程,在未来看到改变。

做一个呼吸,向你有智慧的潜意识说谢谢,谢谢这一次的觉察,谢谢这一次的感悟。

当你准备好,为你内在做一个整理,然后可以慢慢回到当下。

内在资格感和自尊水平低下的时候,我们不敢接受,也不相信自己有能力付出。

所以,我们需要不断练习,把平衡带回给自己,把完整带回给自己。

从内心的无价值感到重建内在的力量和自尊感;

从心的关闭,到心的打开、感恩和流动;

从绷紧,到落落大方地进入这个世界。

在接受和付出之间平衡,当你能够去接受,你就能够去付出。


图片

▲ Unity 选择 Azure 作为其云计算服务商和合作伙伴;两家公司将携手合作,使开发者在 Xbox 和 PC 上触达更多用户

Microsoft 坚定地致力于为创作者赋能。纵观 Windows 的历史,我们一直在培养开发者并促进他们的创造性创新。我们在 Xbox 平台上也是如此,为全球 90 多个国家的大大小小的开发者提供支持。我们的 Azure 云服务向开发者保证,他们可以释放他们的想象力,在 Azure 上构建各类安全且可扩展的应用。这是我们与长期合作伙伴 Unity—实时 3D 技术的全球领导者共有的对创作者的承诺。我们还致力于扩大 3D 内容的创建和分发,将相关工具和技术提供给更广泛的开发者,使得把游戏带给玩家变得比以往更容易。

这就是为什么今天 Unity 选择 Azure 作为其云服务商和合作伙伴,用于构建和运行 Unity 引擎的实时 3D(RT3D)体验。此外,我们很高兴能携手合作,让世界各地的游戏创作者更轻松地将游戏发布到 Xbox 游戏机和 PC 上,从而触达到这些游戏社区。

游戏中诞生的 3D 交互体验的魔力正在迅速转移到非游戏世界。Unity 正在构建一个平台中立的云原生解决方案,以满足从企业开发者到个人开发者的广泛需求。通过让创作者轻松访问 RT3D 模拟工具以及创建真实世界地点和对象的数字孪生的能力,Unity 为创作者提供了一条制作 RT3D 资产的便捷途径,无论是用于游戏还是非游戏世界。为了支持这种演变,创作者需要一个像他们一样动态和创新的技术基础设施。Azure 就是这样的解决方案。以安全全球高扩展性为核心,Azure 已经支持了一些世界上最大的游戏,并将这些久经考验的成果为所有行业的 RT3D 体验提供支持。随着实时模拟的需求成为每个行业的核心,从电子商务到能源,从制造到医疗等等,Unity 和Microsoft 正在建立创造者专属的云,使 3D 艺术家能够在 Azure 上建立和运行这些体验。

我们在全球和各行业实现游戏和类似游戏体验的民主化开发的雄心依赖于强大的合作伙伴关系,尤其是与 Unity 等游戏引擎的合作。Microsoft 与 Unity 之间的合作伙伴关系还将使 Made with Unity 的游戏创作者能够更轻松地通过 Windows 和 Xbox 设备触达他们的玩家,并开启新的成功机会。通过定制化改进的开发人员工具、利用从芯片到云的最新平台创新以及简化游戏发布体验,Unity 创作者将能够实现他们的梦想,将他们的游戏带给全世界更多的玩家。

随着 3D 交互体验在游戏和非游戏世界中的不断发展,Microsoft 和 Unity 正在赋能一波新的创作者来定义未来的数字世界。他们的才能、创造力和同理心,不仅会改变世界,而且会让世界变得更好。