<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[微信读书技术团队]]></title><description><![CDATA[愿不再惧怕每个不眠之夜。]]></description><link>https://tech.weread.qq.com/</link><image><url>http://tech.weread.qq.com/favicon.png</url><title>微信读书技术团队</title><link>https://tech.weread.qq.com/</link></image><generator>Ghost 3.42</generator><lastBuildDate>Mon, 11 May 2026 16:47:46 GMT</lastBuildDate><atom:link href="https://tech.weread.qq.com/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[一种适用于复杂场景下React Native组件曝光检测的方案]]></title><description><![CDATA[随着微信读书项目中React Native的大规模应用，组件的曝光检测已经成为了非常普遍的需求，而且App中存在大量原生ScrollView中嵌套React Native元素、多个ScrollView嵌套等复杂场景，急需一种更加通用且侵入少的方案来实现组件的曝光检测。]]></description><link>https://tech.weread.qq.com/exposure-detection-on-complex-react-native/</link><guid isPermaLink="false">60731b31a2edc4000873fd61</guid><dc:creator><![CDATA[GuanlanHe]]></dc:creator><pubDate>Tue, 20 Apr 2021 11:49:31 GMT</pubDate><content:encoded><![CDATA[<!--kg-card-begin: markdown--><h2 id="1">1. 方案介绍</h2>
<p>React Native中提供了元素位置的检测接口measure和measureInWindow，但接口的能力比较简单，只能通过主动触发获取元素的位置信息，如果在业务中直接使用该接口会带来大量的额外代码，增加业务负担，同时不利于代码维护。</p>
<p>因此，本方案基于Web场景下最流行的Intersection Observer API对measureInWindow进行封装，同时基于事件通知进行检测触发，实现了较为复杂的场景下，组件曝光检测的能力。</p>
<h4 id="intersectionobserverapi">基于Intersection Observer API封装的原因</h4>
<p>1） 该API是Web场景下最为流行的曝光检测方案，因此学习成本低，同时在Web与React Native中进行同构开发更为方便。</p>
<p>2） 封装灵活性高，基于该封装提供的标准接口，我们还可以实现以下功能：</p>
<ul>
<li>图片懒加载: 当图片滚动到可见时才进行加载。</li>
<li>内容无限滚动: 也就是用户滚动到接近内容底部时直接加载更多，而无需用户操作翻页，给用户一种网页可以无限滚动的错觉。</li>
<li>触发式动画: 在用户看见某个区域时执行任务或播放动画。</li>
</ul>
<!--kg-card-end: markdown--><!--kg-card-begin: markdown--><h3 id>与业界方案对比</h3>
<p>业界的类似方案已有react-native-intersection-observer，同时，直接使用原生的measure/measureInWindow也能实现相关功能，以下是本方案与这几个方案的对比:</p>
<!--kg-card-end: markdown--><!--kg-card-begin: markdown--><table>
<thead>
<tr>
<th style="text-align:right">方案</th>
<th style="text-align:center">触发方式</th>
<th style="text-align:center">额外代码</th>
<th style="text-align:center">原生嵌套RN</th>
<th style="text-align:center">ScrollView嵌套</th>
<th style="text-align:center">自定义触发时机</th>
<th style="text-align:center">按需触发检测</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:right">measure/measureInWindow</td>
<td style="text-align:center">主动调用</td>
<td style="text-align:center">多</td>
<td style="text-align:center">×</td>
<td style="text-align:center">✓</td>
<td style="text-align:center">✓</td>
<td style="text-align:center">✓</td>
</tr>
<tr>
<td style="text-align:right">react-native-intersection-observer</td>
<td style="text-align:center">滚动触发</td>
<td style="text-align:center">非常少</td>
<td style="text-align:center">×</td>
<td style="text-align:center">×</td>
<td style="text-align:center">×</td>
<td style="text-align:center">×</td>
</tr>
<tr>
<td style="text-align:right">本方案</td>
<td style="text-align:center"><strong>事件触发</strong></td>
<td style="text-align:center">较少</td>
<td style="text-align:center">✓</td>
<td style="text-align:center">✓</td>
<td style="text-align:center">✓</td>
<td style="text-align:center">✓</td>
</tr>
</tbody>
</table>
<!--kg-card-end: markdown--><!--kg-card-begin: markdown--><h3 id>采用事件触发模式的原因与优势</h3>
<ul>
<li><strong>兼容原生View嵌套React Native子元素的场景</strong></li>
</ul>
<p>事件触发型检测可以将触发器放在原生代码中，只需要给外部滚动组件绑定触发器，就可以在内嵌于原生View的RN组件中使用IntersectionObserver进行曝光检测。</p>
<!--kg-card-end: markdown--><figure class="kg-card kg-image-card"><img src="https://weread-1258476243.file.myqcloud.com/tech/2021/04/RNIO7.png" class="kg-image" alt></figure><!--kg-card-begin: markdown--><ul>
<li><strong>兼容自定义触发时机</strong></li>
</ul>
<p>除了onScroll事件以外，scrollView同时也提供了类似onMomentumScrollEnd(滚动动画结束)等事件，对比用HOC包裹ScrollView的方案，使用事件触发可以根据需求给不同的滚动事件绑定触发器，可以满足更多的曝光场景需求。</p>
<ul>
<li><strong>兼容多个ScrollView嵌套的场景</strong></li>
</ul>
<p>通过给两个ScrollView绑定相同scope的触发事件，可以实现多个嵌套的ScrollView触发同一个IntersectionObserver的曝光事件，提高了代码的可复用性。</p>
<!--kg-card-end: markdown--><figure class="kg-card kg-image-card"><img src="https://weread-1258476243.file.myqcloud.com/tech/2021/04/RNIO8.png" class="kg-image" alt></figure><!--kg-card-begin: markdown--><h2 id="2">2. 实现过程</h2>
<p><strong>1) 核心原理</strong><br>
React Native提供了measureInWindow接口，可以获取组件在屏幕中的位置。</p>
<!--kg-card-end: markdown--><figure class="kg-card kg-image-card"><img src="https://weread-1258476243.file.myqcloud.com/tech/2021/04/image.png" class="kg-image" alt></figure><p>measureInWindow可以提供x、y、width、height四个关键位置和大小信息，通过计算可以确定组件的四个端点，从而计算出曝光比例。</p><!--kg-card-begin: markdown--><p><strong>2) rootMargin与thresholds参数</strong><br>
本实现依据Intersection Observer API标准实现了rootMargin与thresholds能力，便于使用者根据需求定制不同的触发时机与触发位置。</p>
<p><strong>rootMargin：</strong> 用于设置触发区域与屏幕四边的距离，方便排除滚动条、状态栏与Tab栏的影响。</p>
<p><strong>thresholds：</strong> 用于设置触发阈值，范围是0-1，在滚动过程中，每当元素的相交比例与上次检测相比，超过或低于任意一个阈值，都会触发事件回调。</p>
<pre><code>例如：当设定thresholds为[0.5]时。
    1.前一次检测相交比例为0.4，后一次检测时相交比例为0.6触发回调intersecting = true。
    2.同理前一次检测相交比例为0.6，后一次检测时相交比例为0.4也会触发回调，此时intersecting = false。</code></pre>
<!--kg-card-end: markdown--><figure class="kg-card kg-image-card"><img src="https://weread-1258476243.file.myqcloud.com/tech/2021/04/RNIO4.png" class="kg-image" alt></figure><!--kg-card-begin: markdown--><p><strong>3) 封装便于React Native使用的IntersectionObserverView</strong><br>
由于直接绑定Intersection Observer API的书写方式不太符合React的思维模式，同时通常用一个View包裹需要监听的元素不会带来太大的麻烦，因此本实现在API标准的基础上封装了一个更便于React使用的IntersectionObserverView组件。以下是两种使用方式的对比。</p>
<!--kg-card-end: markdown--><figure class="kg-card kg-code-card"><pre><code class="language-javascript">const intersectionObserver = useRef(
    new IntersectionObserver(scope, callbackFunction, {
      rootMargin,
      thresholds,
      throttle,
    })
);
const observeTargetRef = useRef();

useEffect(() =&gt; {
	intersectionObserver.observe(observeTargetRef.current);
    return () =&gt; {
        intersectionObserver.unobserve(observeTargetRef.current);
    };
}, [])

return (
    &lt;View ref={observeTargetRef} collapsable={false}&gt;
        {/* Your own view */}
    &lt;/View&gt;
);</code></pre><figcaption>直接使用IntersectionObserver</figcaption></figure><figure class="kg-card kg-code-card"><pre><code class="language-html">&lt;IntersectionObserverView
    scope="YourOwnScope"
    thresholds={[0.8]}
    onIntersectionChange={onTagIntersectionChange}
&gt;
    {/* your own view */}
&lt;/IntersectionObserverView&gt;
</code></pre><figcaption>使用IntersectionObserverView</figcaption></figure><!--kg-card-begin: markdown--><h3 id>开发中遇到的问题</h3>
<ul>
<li>在安卓中React Native会对只起包裹作用的View进行移除，导致对应的View被measure时位置信息返回undefined，解决方案：将需要measure的View的collapsable属性设置为false。</li>
</ul>
<!--kg-card-end: markdown--><figure class="kg-card kg-image-card"><img src="https://weread-1258476243.file.myqcloud.com/tech/2021/04/image-1.png" class="kg-image" alt></figure><!--kg-card-begin: markdown--><h2 id="3">3. 使用示例</h2>
<!--kg-card-end: markdown--><figure class="kg-card kg-code-card"><pre><code class="language-html">&lt;IntersectionObserverView
    scope="YourOwnScope"
    thresholds={[0.8]}
    onIntersectionChange={onTagIntersectionChange}
&gt;
 {/* your own view */}
&lt;/IntersectionObserverView&gt;
</code></pre><figcaption>需要被检测的对象</figcaption></figure><figure class="kg-card kg-code-card"><pre><code class="language-javascript">const onScroll = useCallback(
    (event) =&gt; {
        IntersectionObserver.emitEvent('YourOwnScope');
    },
    [],
);
return (
    &lt;ScrollView onScroll={onScroll}&gt;
    	{/* Scroll view contains IntersectionObserverView */}
    &lt;/ScrollView&gt;
);</code></pre><figcaption>从React Native触发检测</figcaption></figure><figure class="kg-card kg-code-card"><pre><code class="language-java">getReactApplicationContext()
  .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
  .emit("IntersectionObeserverEvent", { scope: 'YourOwnScope' });</code></pre><figcaption>从原生触发检测(Android)</figcaption></figure><!--kg-card-begin: markdown--><h3 id>参数说明</h3>
<p><strong>1) IntersectionObserver / IntersectionObserverView</strong></p>
<table>
<thead>
<tr>
<th style="text-align:left">参数名</th>
<th style="text-align:left">类型</th>
<th style="text-align:left">含义</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">scope</td>
<td style="text-align:left">string</td>
<td style="text-align:left">当前View所属场景，用于触发时区分</td>
</tr>
<tr>
<td style="text-align:left">rootMargin</td>
<td style="text-align:left">{top: number, left: number, bottom: number, right: number}</td>
<td style="text-align:left">触发区域距离屏幕四边的距离</td>
</tr>
<tr>
<td style="text-align:left">thresholds</td>
<td style="text-align:left">number[]</td>
<td style="text-align:left">相交阈值，当相交比例达到设定值时触发回调</td>
</tr>
<tr>
<td style="text-align:left">throttle</td>
<td style="text-align:left">number</td>
<td style="text-align:left">触发检测的时间间隔(ms)</td>
</tr>
</tbody>
</table>
<!--kg-card-end: markdown--><!--kg-card-begin: markdown--><p><strong>2) 回调函数</strong><br>
回调函数函数的回调值是一个由所有在本次回调命中了阈值的View，数组的每一个元素结构如下：</p>
<table>
<thead>
<tr>
<th style="text-align:left">key</th>
<th style="text-align:left">类型</th>
<th style="text-align:left">含义</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">boundingClientRect</td>
<td style="text-align:left">{top: number, left: number, bottom: number, right: number}</td>
<td style="text-align:left">元素四边位置</td>
</tr>
<tr>
<td style="text-align:left">intersectionRatio</td>
<td style="text-align:left">number</td>
<td style="text-align:left">相交比例</td>
</tr>
<tr>
<td style="text-align:left">intersectionRect</td>
<td style="text-align:left">{top: number, left: number, bottom: number, right: number}</td>
<td style="text-align:left">相交区域四边位置</td>
</tr>
<tr>
<td style="text-align:left">target</td>
<td style="text-align:left">Ref</td>
<td style="text-align:left">触发元素的Ref</td>
</tr>
<tr>
<td style="text-align:left">isInsecting</td>
<td style="text-align:left">boolean</td>
<td style="text-align:left">是否大于任意一个相交阈值</td>
</tr>
</tbody>
</table>
<p>注：使用IntersectionObserverView时由于只有单个View，回调只有单个Entry。</p>
<!--kg-card-end: markdown--><!--kg-card-begin: markdown--><h2 id="4">4. 性能优化</h2>
<p><strong>1) 通过throttle控制触发频率</strong></p>
<pre><code class="language-html">&lt;IntersectionObserverView
    scope=&quot;YourOwnScope&quot;
    thresholds={[0.8]}
    onIntersectionChange={onTagIntersectionChange}
    throttle={200}
&gt;
 {/* your own view */}
&lt;/IntersectionObserverView&gt;
</code></pre>
<p><strong>2) 通过scope实现同一个ScrollView中的元素分场景按需触发</strong></p>
<p>页面中通常会存在多个Intersection Observer，但不是每次滚动时都需要触发所有Intersection Observer的检测，因此通过给他们设置不同的Scope，在滚动事件中根据需求触发所需的Scope，可以获得更好的性能表现。</p>
<pre><code class="language-html">&lt;IntersectionObserverView scope=&quot;Animation&quot;&gt;
    &lt;!-- Item with intersection animations --&gt;
&lt;/IntersectionObserverView&gt;
&lt;IntersectionObserverView scope=&quot;Exposure&quot;&gt;
    &lt;!-- Item needs report exposure --&gt;
&lt;/IntersectionObserverView&gt;
</code></pre>
<pre><code class="language-javascript">// 示例: 按需触发
const onScroll = () =&gt; {
    if (this.state.needAnimation) {
        IntersectionObserver.emitEvent('Animation');
    }
    IntersectionObserver.emitEvent('Exposure');
}
</code></pre>
<p><strong>3) 为没有绑定元素的Intersection Observer解绑监听事件</strong></p>
<pre><code class="language-typescript">public unobserve = (target: IElementRef) =&gt; {
    const index = this.targets.indexOf(target);
    if (index &gt;= 0) {
      this.targets.splice(index, 1);
    }
    if (this.targets.length &lt;= 0) {
      this.emitterSubscription?.remove();
    }
};
</code></pre>
<p>解绑这些监听器有助于减轻事件队列的负担同时有利于内存回收。</p>
<!--kg-card-end: markdown--><!--kg-card-begin: markdown--><h3 id>性能测试</h3>
<p>我们选取了性能相对较差的 OPPO A59m 平台作为测试平台。在界面中添加十余个IntersectionObserverView后，不断滚动，可见帧率维持稳定。</p>
<!--kg-card-end: markdown--><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://weread-1258476243.file.myqcloud.com/tech/2021/04/image-3.png" class="kg-image" alt><figcaption>PerfDog性能测试报告</figcaption></figure><!--kg-card-begin: markdown--><h2 id="5todos">5. Todos</h2>
<ol>
<li>支持设置root参数来指定触发相对的RootView。</li>
<li>实现能为任意组件绑定IntersectionObserver的HOC与Hook，简化为任意其他组件绑定IntersectionObserver时的代码修改。</li>
</ol>
<!--kg-card-end: markdown--><!--kg-card-begin: markdown--><h2 id="6">6. 项目仓库</h2>
<!--kg-card-end: markdown--><figure class="kg-card kg-bookmark-card"><a class="kg-bookmark-container" href="https://github.com/gtbl2012/react-native-intersection-observer"><div class="kg-bookmark-content"><div class="kg-bookmark-title">gtbl2012/react-native-intersection-observer</div><div class="kg-bookmark-description">Intersection observer for complex or embedded react native view - gtbl2012/react-native-intersection-observer</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://github.githubassets.com/favicons/favicon.svg"><span class="kg-bookmark-author">GitHub</span><span class="kg-bookmark-publisher">gtbl2012</span></div></div><div class="kg-bookmark-thumbnail"><img src="https://opengraph.githubassets.com/e0a3a5c61323206befe88cd4b9b2f106cd49a0f16030f7f95206cd7579d2bb44/gtbl2012/react-native-intersection-observer"></div></a></figure><figure class="kg-card kg-bookmark-card"><a class="kg-bookmark-container" href="https://www.npmjs.com/package/rn-intersection-observer"><div class="kg-bookmark-content"><div class="kg-bookmark-title">rn-intersection-observer</div><div class="kg-bookmark-description">Intersection observer for complex or embedded react native view</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://static.npmjs.com/1996fcfdf7ca81ea795f67f093d7f449.png"><span class="kg-bookmark-author">npm</span></div></div><div class="kg-bookmark-thumbnail"><img src="https://static.npmjs.com/338e4905a2684ca96e08c7780fc68412.png"></div></a></figure>]]></content:encoded></item><item><title><![CDATA[Matomo 从了解到落地——页面流量统计与分析最佳实践]]></title><description><![CDATA[在开发面向内部使用的「内容管理平台」的过程中，我们不时会收到一些页面问题的反馈，但在本地调试的过程中，有大量无法在本地重现的问题，这些问题的出现跟用户的访问设备、网络环境、访问路径可能存在关联。为了方便快捷地去定位这些问题，我们试图为所有页面点击操作都加上打点记录，但在实际操作中，由于业务变更频繁，开发框架的限制，展示打点数据较为复杂等因素，通过打点排查问题的实际效果并不理想，因此我们希望引入完整的流量统计和用户行为分析来定位问题。]]></description><link>https://tech.weread.qq.com/matomo-best-practices/</link><guid isPermaLink="false">60619d9fa2edc4000873fc5f</guid><dc:creator><![CDATA[Kayo Lee]]></dc:creator><pubDate>Wed, 07 Apr 2021 08:29:46 GMT</pubDate><content:encoded><![CDATA[<!--kg-card-begin: markdown--><h2 id>背景</h2>
<p>在开发面向内部使用的「内容管理平台」的过程中，我们不时会收到一些页面问题的反馈，但在本地调试的过程中，有大量无法在本地重现的问题，这些问题的出现跟用户的访问设备、网络环境、访问路径可能存在关联。为了方便快捷地去定位这些问题，我们试图为所有页面点击操作都加上打点记录，但在实际操作中，由于业务变更频繁，开发框架的限制，展示打点数据较为复杂等因素，通过打点排查问题的实际效果并不理想，因此我们希望引入完整的流量统计和用户行为分析来定位问题。</p>
<h3 id>不同的方案分析对比</h3>
<p>对于流量统计和用户行为分析记录的工具，行业内已经有大量成熟的解决方案，相对于自行打点，这些专门的流量通过平台和工具对于业务的基本没有侵入性，也解决了如何展示数据的问题。这些平台和工具中，有著名的 Google Analytics、百度统计、WebTrends 等，也有相对冷门的今天的主角 —— Matomo，而这些方案之间各有优劣：</p>
<table>
<thead>
<tr>
<th style="text-align:left">解决方案/平台</th>
<th style="text-align:left">优势</th>
<th style="text-align:left">劣势</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">Google Analytics</td>
<td style="text-align:left">部署简单，只需在页面加入 JS 追踪器代码，数据分析快（小时级别），功能强大，分析维度丰富</td>
<td style="text-align:left">数据量大的时候偶尔会丢失数据，无法定制化</td>
</tr>
<tr>
<td style="text-align:left">Adobe Analytics</td>
<td style="text-align:left">数据展示清晰明了，功能强大</td>
<td style="text-align:left">部署复杂，只有付费版本，技术支持和文档都较少</td>
</tr>
<tr>
<td style="text-align:left">WebTrends</td>
<td style="text-align:left">数据分析维度丰富，报告全面，监控过程安全</td>
<td style="text-align:left">主要针对大客户，费用非常高</td>
</tr>
<tr>
<td style="text-align:left">CNZZ</td>
<td style="text-align:left">部署和接入简单，分析功能易用，报告简洁</td>
<td style="text-align:left">没有用户细分数据，也不支持用户路径分析，功能较为单一</td>
</tr>
<tr>
<td style="text-align:left">Matomo</td>
<td style="text-align:left">对标 Google Analytics 的功能，接入简单，功能强大，分析维度丰富，支持私有化部署，包括代码和数据都可以私有化处理，有强大的插件机制，可以自行开发功能</td>
<td style="text-align:left">私有化需要自行部署和维护服务器、数据库等，部分分析功能需要二次开发</td>
</tr>
</tbody>
</table>
<p>通过对比，Matomo 整体功能比较强大，对标了 Google Analytics 但在安全性和私密性方面更优，支持私有化部署，代码和数据都可以不透露给第三方，并且可以通过插件的机制配合业务实现自定义，这些优点都是我们最终选择 Matomo 作为「内容管理平台」用户记录的工具的原因。</p>
<h2 id="matomo">Matomo 是什么？</h2>
<p>这里介绍一下 Matomo，作为一套基于 PHP 与 MySQL 的网页流量统计和分析平台，它的大部分功能已经开源，并且做了很好的封装，可以轻松地进行私有化部署，它的功能主要分成两块：</p>
<ul>
<li>收集并存储页面访问数据，主要是用户信息，如设备型号、分辨率、用户地区、来源，以及页面信息，如页面访问路径、访问操作等。</li>
<li>对收集起来的数据进行指标量化并可视化的展示，例如用户设备型号分布、地区分布、某个页面的浏览人数、访问最多的页面、<strong>某个用户在某个页面的访问路径和具体操作</strong>等，并且在收集数据时，<strong>Matomo 会有大量的策略保护用户隐私，例如上报 IP 时隐藏最后一位字节等。</strong></li>
</ul>
<p>在实际使用时，用户信息的上报以及页面的访问路径，只需要安装并引入 Matomo 即可实现，无需额外的配置。但是<strong>开发者可以通过接口增强上报的数据，例如上报某个弹窗的展示，或者上报某个请求的结果，这样最终可以在平台上展示出完整的用户访问路径和操作，结合业务日志，可以很准确地定位问题以及还原问题的触发路径。</strong></p>
<h2 id="matomo">Matomo 落地到业务</h2>
<p>在引入 Matomo 之前，先说明一下 Matomo 的主要组成追踪器和 Matomo 服务端，追踪器基于 JS 实现，需要在网页引入，用于上报数据。服务端主要提供了三个功能：</p>
<ul>
<li>HTTP 接口，追踪器可以收集所在网页的数据但不上报，通过 HTTP 接口发送给 Matomo。</li>
<li>归档任务运行并预处理数据，默认分为实时动态处理（页面访问数据，用户访问轨迹）和 cron 任务处理（用户维度的列表）。</li>
<li>可视化展现数据，也可以数据接口或者报表接口来访问这些数据。</li>
</ul>
<h2 id>引入简单落地不易</h2>
<p>Matomo 有很成熟封装，因此本身部署很简单，主要分为两个步骤：</p>
<ol>
<li>部署私有化 Matomo 服务。</li>
<li>在需要流量统计ide页面上引入追踪器。</li>
</ol>
<p>其中部署私有化服务只需要下载 Matomo 的程序并上传到服务端，然后打开访问地址就可以使用引导程序部署服务，包括检测服务器环境是否符合要求，填写数据库信息，创建管理账号等，具体参考<a href="https://github.com/matomo-org/matomo#install-matomo">官方文档</a>。</p>
<p>但在实际落地到内容平台的过程中，却遇到了问题——我们需要基于 Docker 进行部署。</p>
<p>由于业务的部署都基于 Docker 和 k8s 进行，因此私有化的 Matomo 也需要基于此进行部署，这样会带来几个问题：</p>
<ol>
<li>Matomo 的设置分成系统配置与功能设置，其中功能设置储存在 MySQL 中，而系统设置则储存在本地的配置文件中，当部署多个容器时，配置无法对齐，另外 Docker 重新部署后，这些配置修改也会丢失。</li>
<li>这套部署需要域名 + 路径的形式访问 Matomo，Matomo 社区镜像中是使用 Apache2 进行路由处理的，而 Apache2 默认的配置并不适配路径，需要修改 Apache 的配置文件。</li>
</ol>
<h3 id="dockermatomo">解决在 Docker 中部署 Matomo 的问题</h3>
<p>Matomo 有官方发布的社区镜像可以直接使用，但为了解决上述的问题，需要在构建 Docker 镜像时进行额外的处理。</p>
<h4 id>解决配置丢失的问题</h4>
<p>Matomo 的配置文件是 <code>config/config.ini.php</code>，不跟随版本管理，为了获取一份默认的配置文件，可以用社区镜像预先部署好一个 Matomo 容器，并在容器中获取一份默认的配置文件，例如：</p>
<pre><code class="language-php">[database]
host = &quot;${MATOMO_DATABASE_HOST}&quot;
username = &quot;${MATOMO_DATABASE_USERNAME}&quot;
password = &quot;${MATOMO_DATABASE_PASSWORD}&quot;
dbname = &quot;${MATOMO_DATABASE_DBNAME}&quot;
tables_prefix = &quot;${MATOMO_DATABASE_TABLES_PREFIX}&quot;
charset = &quot;utf8mb4&quot;
multi_server_environment = 1
enable_installer = 0

[General]
force_ssl = 0
assume_secure_protocol = 1
proxy_client_headers[] = &quot;HTTP_X_FORWARDED_FOR&quot;
proxy_client_headers[] = &quot;HTTP_X_ORIGINAL_FORWARDED_FOR&quot;
proxy_host_headers[] = &quot;HTTP_X_FORWARDED_HOST&quot;
salt = &quot;xxxx&quot; // 加密串，用于解密配置内容
trusted_hosts[] = &quot;weread.qq.com&quot;

[Plugins]
Plugins[] = &quot;CorePluginsAdmin&quot;
// ...
// 需要启动的插件列表，由于篇幅有限，省略默认的启动插件

[PluginsInstalled]
PluginsInstalled[] = &quot;Diagnostics&quot;
// ...
// 所有插件列表，，由于篇幅有限，省略默认的插件列表
</code></pre>
<p>复制出默认的配置文件后，即可根据业务进行修改，主要包括：</p>
<ol>
<li>数据库的配置，建议使用环境变量进行配置。</li>
<li>salt 是用于解密配置内容的加密串，保留默认配置中的值即可。</li>
<li><code>trusted_hosts[]</code> 是部署 Matomo 的域名，支持多个域名配置，必须正确填写，否则无法使用。</li>
<li><code>Plugins[]</code> 和 <code>PluginsInstalled[]</code> 分别是需要启用的插件和总插件列表，有需要调整插件的激活状态可以自行调整。</li>
</ol>
<p>在修改完成后，可以利用 Docker 的命令把自定义的配置文件覆盖到镜像中，例如：</p>
<pre><code># 复制配置文件
COPY config.ini.php /var/www/html/config/config.ini.php
</code></pre>
<h4 id>解决子目录部署的问题</h4>
<p>Matomo 部署完成后，会以 weread.qq.com/weread-matomo 的形式去访问 Matomo 的服务，因此根据默认的 Apache2 配置，会尝试在 weread-matomo 这个目录中读取 Matomo，但实际上我们的 Matomo 是部署在根目录的，因此需要修改 Apache2 的配置文件，把针对 <code>^/weread-matomo</code> 的访问指向根目录。</p>
<p>值得注意的是，出于安全考虑，我们不希望把 Matomo 的管理后台暴露到外网，因此在 Apache2 的配置中，可以通过正则指定只有追踪器相关的文件暴露到外网可以访问，方便业务引入。在了解了 Matomo 的源码后，追踪器相关的文件主要有 <code>matomo.js</code> 和 <code>matomo.php</code>，其中 <code>matomo.php</code> 结尾会带有参数，因此最终的 AliasMatch 规则如下：</p>
<pre><code>&lt;VirtualHost *:80&gt;
	ServerAdmin webmaster@localhost
	DocumentRoot /var/www/html

	ErrorLog ${APACHE_LOG_DIR}/error.log
	CustomLog ${APACHE_LOG_DIR}/access.log combined

    AliasMatch ^/weread-matomo/(matomo\.(js|php).*) /var/www/html/$1

    &lt;Directory /var/www/html&gt;
        Options All
        AllowOverride All
        order allow,deny
        allow from all
    &lt;/Directory&gt;
&lt;/VirtualHost&gt;
</code></pre>
<p>社区镜像中 Apache2 的配置文件存放在 <code>/etc/apache2/sites-available/000-default.conf</code>，把上面的配置内容在本地保存一份 <code>000-default.conf</code> 后，在构建 Docker 镜像时利用命令覆盖默认的 Apache2 配置：</p>
<pre><code>COPY 000-default.conf /etc/apache2/sites-available/000-default.conf
</code></pre>
<h4 id>无法显示城市信息</h4>
<p>在解决了上面两个问题后，Matomo 的私有化部署基本已经跑通了，但后续我们发现，这样上报的数据中，并没有显示城市信息，Matomo 是基于 IP 信息来判定城市的，而 Matomo 自带的 IP 库仅能识别国家信息。</p>
<p><img src="https://weread-1258476243.file.myqcloud.com/tech/2021/04/------.png" alt="无法显示城市信息"></p>
<p>如上图所示，城市都显示为“未知”。为了解决这个问题，需要引入 IP 地址库，Matomo 支持 DBIP 和 GeoIP 2 两个外部的地址库，地址库的格式都是一种特殊的地址库 <code>.mmdb</code> 格式。</p>
<p>这里建议使用 GeoIP，在<a href="https://dev.maxmind.com/geoip/geoip2/geolite2/">这里</a>完成注册后，可以下载 <code>.mmdb</code> 格式的地址库。为了让 Matomo 识别出额外的地址库，需要把 <code>.mmdb</code> 放置到项目的 <code>misc</code> 目录，但由于 Matomo 使用了 Docker 部署，因此需要用 Docker 命令把 <code>.mmdb</code> 文件复制到容器的 <code>misc</code> 目录，最终完整的 Dockerfile 如下：</p>
<pre><code># Dockerfile

FROM matomo:latest

MAINTAINER kayoli

# 复制 Apache2 配置文件
COPY 000-default.conf /etc/apache2/sites-available/000-default.conf

# 复制 Matomo 配置文件
COPY config.ini.php /var/www/html/config/config.ini.php

# 引入 IP 地址库，用于显示 IP 对应的城市
COPY mmdb/GeoLite2-ASN.mmdb /var/www/html/misc/GeoLite2-ASN.mmdb
COPY mmdb/GeoLite2-City.mmdb /var/www/html/misc/GeoLite2-City.mmdb
COPY mmdb/GeoLite2-Country.mmdb /var/www/html/misc/GeoLite2-Country.mmdb
</code></pre>
<h3 id>前端引入追踪器也有坑</h3>
<h4 id>页面引入追踪器</h4>
<p>经过上面的处理，已经解决了在 Docker 中部署服务端的问题，在 Matomo 的部署引导程序在完成后，会输出一段 JS 代码，用于给业务前端引入追踪器，例如：</p>
<pre><code class="language-javascript">&lt;!-- Matomo --&gt;
&lt;script type=&quot;text/javascript&quot;&gt;
  var _paq = window._paq = window._paq || [];
  /* tracker methods like &quot;setCustomDimension&quot; should be called before &quot;trackPageView&quot; */
  _paq.push(['trackPageView']);
  _paq.push(['enableLinkTracking']);
  (function() {
    var u=&quot;//xxxx&quot;; // 私有化部署 Matomo 的域名
    _paq.push(['setTrackerUrl', u+'matomo.php']);
    _paq.push(['setSiteId', '1']);
    var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
    g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
  })();
&lt;/script&gt;
&lt;!-- End Matomo Code --&gt;
</code></pre>
<p>Matomo 的追踪器包含了大量的选项和方法，主要包括：</p>
<ol>
<li>Tracker Object，用于记录某个行为，例如上面代码中的 <code>trackPageView</code> 则用于记录某个页面被访问，<code>enableLinkTracking</code> 则是用于开启链接跳转时自动记录的功能。</li>
<li>Configuration of the Tracker Object，用于配置 Tracker Object，例如 <code>setDocumentTitle</code> 可以覆盖上报页面的标题，默认是获取 <code>document.title</code>。</li>
<li>Ecommerce，电商相关的方法，提供了一系列记录商品信息的方法。</li>
<li>Managing Consent，提供了一种机制来管理用户的跟踪上报。</li>
</ol>
<p>具体可以参考<a href="https://developer.matomo.org/api-reference/tracking-javascript">文档</a>。</p>
<h4 id="vuespa">自动记录 Vue SPA 的页面跳转</h4>
<p>成功引入追踪器后发现，「内容管理平台」上报的用户行为，只有打开页面的操作，跳转页面并没有成功上报，但是默认的追踪器代码中，已经开启了 `` 选项。</p>
<p>在 Matomo 的源码中，可以看到对 `` 的说明：</p>
<pre><code class="language-javascript"> // @param bool enable Defaults to true.
 //    If &quot;true&quot;, use pseudo click-handler (treat middle click and open contextmenu as
 //    left click). A right click (or any click that opens the context menu) on a link
 //    will be tracked as clicked even if &quot;Open in new tab&quot; is not selected.
 //      If &quot;false&quot; (default), nothing will be tracked on open context menu or middle click.
 //    The context menu is usually opened to open a link / download in a new tab
 //    therefore you can get more accurate results by treat it as a click but it can lead
 //    to wrong click numbers.
 //
this.enableLinkTracking = function (enable) {
    linkTrackingEnabled = true;
    // ...
};
</code></pre>
<p>也就是说，这个选项仅对 link，也就是常见的 <code>&lt;a href=&quot;xxxx&quot;&gt;链接&lt;/a&gt;</code> 这种形式的跳转才起作用，而「内容管理平台」是基于 Vue 开发的 Spa，页面跳转不是链接跳转，因此上报的记录里只有打开页面。</p>
<p>要解决这个问题，可以在 Vue 进行跳转时主动调用 Matomo 的上报，但实际上已经有开源的插件实现了这个，例如<a href="https://github.com/AmazingDreams/vue-matomo">vue-matomo</a>，具体使用可以参考它的<a href="https://github.com/AmazingDreams/vue-matomo#usage">使用文档</a>。</p>
<p>值得注意的是，vue-matomo 对 matomo 的初始参数进行封装，除了文档中列出来的选项，其他选项在初始化的时候是无效的，可以在 vue-matomo 初始化后，通过 <code>_paq.push(['xxx'])</code> 调用，<code>_paq</code> 对象的 <code>push</code> 方法已经被重写，调用 <code>push</code> 方法实际上相当于把某个方法放入调用队列并进行调用。</p>
<h2 id="matomo">Matomo 的最佳实践</h2>
<p>经过上面的踩坑和填坑后，Matomo 最终得以在「内容管理平台」中落地投入使用，在经过一段时间的实践后，现有的自动记录还是不能满足我们的需求，例如我们需要自动上报 JS 错误信息，在点击 UI 元素时也需要上报，另外还需要在请求错误时进行自动上报。在经过一系列实践后，总结了一些最佳实践。</p>
<h3 id="js">自动记录 JS 错误</h3>
<p>在新版 Matomo 中，支持开启自动上报 JS 错误的功能，但功能尚未正式发布，因此官方文档中没有该功能的说明，需要调用的话可以通过 <code>window._paq.push(['enableJSErrorTracking']);</code> 开启该功能，为了保护 <code>_paq</code> 没有初始化好的情况，可以先判断 <code>_paq</code> 是否存在，例如：</p>
<pre><code class="language-javascript">const enableJSErrorTracking = (): void =&gt; {
  if (window._paq) {
    window._paq.push(['enableJSErrorTracking']);
  } else {
    console.warn('can not found window._paq');
  }
};
</code></pre>
<h3 id>主动上报更多操作</h3>
<p>除了链接跳转，页面中通常还会有一些 UI 操作不涉及链接变化，也不涉及请求，这类操作可以使用追踪器提供的 Tracker Object 进行主动上报，为了方便起见，可以抽取成工具方法，例如：</p>
<pre><code class="language-javascript">// 上报一个事件，例如点击事件，播放事件等，在主动上报中比较常用。
export const trackEvent = (category: string, action: string, name?: string, value?: number): void =&gt; {
  if (window._paq) {
    window._paq.push(['trackEvent', category, action, name, value]);
  } else {
    console.warn('can not found window._paq');
  }
};

// 二次封装，专门上报弹窗的动作，例如 action 参数可以填写 show, close
export const trackDialogEvent = (action: string, name?: string, value?: number): void =&gt; {
  if (window._paq) {
    window._paq.push(['trackEvent', 'Dialog', action, name, value]);
  } else {
    console.warn('can not found window._paq');
  }
};

// 上报错误
export const trackErrorEvent = (action: string, name?: string, value?: number): void =&gt; {
  if (window._paq) {
    window._paq.push(['trackEvent', 'Error', action, name, value]);
  } else {
    console.warn('can not found window._paq');
  }
};

// 上报搜索动作
export const trackSiteSearch = (keyword: string, category?: string, resultsCount?: string): void =&gt; {
  if (window._paq) {
    window._paq.push(['trackSiteSearch', keyword, category, resultsCount]);
  } else {
    console.warn('can not found window._paq');
  }
};
</code></pre>
<h3 id>请求失败自动上报</h3>
<p>业务中涉及请求的操作通常都比较关键，请求失败自动上报有利于记录下完整的用户动作路径，方便定位问题，在我们的业务中，我们的请求都是使用 Axios 发出的，因此可以利用 axios interceptors 劫持所有请求，在遇到指定错误时自动上报到 Matomo，例如：</p>
<pre><code class="language-javascript">const baseURL = 'xxx';
// axios instance
const service = axios.create({
  baseURL,
  timeout: 60000,
});

service.interceptors.response.use(
  (response: AxiosResponse) =&gt; {
    const errCode = response.data &amp;&amp; (response.data.errCode || response.data.errcode);
    if (errCode &amp;&amp; errCode &lt; 0) {
      const URL = response.config &amp;&amp; response.config.url;
      const errMsg = response.data &amp;&amp; (response.data.errMsg || response.data.errMsg);
      trackErrorEvent(URL, errMsg, errCode);
    }
    return response;
  },
  (error) =&gt; {
    return Promise.reject(error);
  }
);
</code></pre>
<p>至此，已经可以很准确展示用户在访问页面时的完整操作路径了，开发者可以通过这些操作路径，结合业务日志，方便地去定位问题以及还原问题。</p>
<p><img src="https://weread-1258476243.file.myqcloud.com/tech/2021/04/matomo.png" alt="利用 Matomo 上报完整的用户操作"></p>
<h3 id>效果展示</h3>
<p>经过以上的处理，现在已经可以上报非常丰富的访问数据，以及用户路径了，例如：</p>
<h4 id>访客分析 - 访问日志</h4>
<p><img src="https://weread-1258476243.file.myqcloud.com/tech/2021/04/202103252209_1.png" alt="访客分析 - 访问日志"></p>
<p>可以看到，界面上显示了完整的用户操作，通过时间轴的形式，配合不同的关键词和 icon 可以很好地呈现出实际的操作路径。</p>
<h4 id>访客分析 - 设备</h4>
<p><img src="https://weread-1258476243.file.myqcloud.com/tech/2021/04/202103252213.png" alt="访客分析 - 设备"></p>
<p>除了设备，在 Matomo 中还有地区等用户维度，并且 Matomo 在不同的数据展示中，例如目标转化率等，都可以基于这些不同的维度进行展示，对于分析用户组成相当方便。</p>
<h4 id>转化与收益分析 - 概览</h4>
<p>Matomo 支持设定指定的目标，用于计算转化率，并进行多个维度的展示，包括转化流向，每个阶段的转化人数和转化率等，并且可以通过不同的维度，例如渠道类型、城市、设备类型分别展示各种维度下的转化数据，这也是 Matomo 一种重要的特性，数据的展示维度丰富。</p>
<p><img src="https://weread-1258476243.file.myqcloud.com/tech/2021/04/202104022005.png" alt="转化与收益分析 - 概览"></p>
<p>另外，Matomo 的插件机制也非常强大，可以插入自定义的数据，注入到各个界面或者基于 Matomo 自身收集的数据重新展示，基于篇幅所限，后续再对 Matomo 的插件机制进行实践说明。</p>
<h2 id="matomo">Matomo 的性能分析与局限性</h2>
<p>从上面的说明中可以看出，Matomo 的分析功能强大，分析的维度也很丰富，但同时也带来了较大的服务端资源消耗。</p>
<p>Matomo 的架构可以支撑千万级甚至亿级的月 PV，但同时对于服务端的 CPU，RAM 和硬盘空间都有相应的要求。因此在实际使用时，需要注意当前服务端的配置是否足以支撑上报页面的 PV 量，否则会导致 Matomo 无法及时处理数据甚至崩溃。</p>
<h3 id>量化性能分析</h3>
<ul>
<li>Matomo 的默认配置是 1GB 内存，在默认配置下，Matomo 可以轻松支撑 1000 PV/天的访问量，对于这种级别的访问量，通常是一些内部平台或者面向特定人群的辅助页面。</li>
<li>对于 3000 PV/天访问量的业务，则建议使用<strong>2核 CPU，2GB RAM，50GB 硬盘</strong>的配置。</li>
<li>对于 30000 PV/天访问量的业务，则建议使用<strong>4核 CPU，8GB RAM，250GB 硬盘</strong>的配置，这个量级的访问可以是<strong>面向大众用户的业务页面了。</strong></li>
<li>对于 300000 PV/天访问量的业务，建议把 PHP 服务端和 MySQL 分开部署，对于这种量级的业务，MySQL 的瓶颈会更加明显，把 MySQL 部署进行单独部署，会更加稳定，建议最低的配置是<strong>8核 CPU，16GB RAM, 100GB 硬盘的机器作为 PHP 服务端，8核 CPU, 16GB RAM, 400GB 硬盘作为 MySQL 服务，</strong>。</li>
<li>对于更高访问量的业务，可以再叠加机器配置，硬盘空间主页是给 MySQL 消耗用的，<strong>一个参考数据是：大概每增加500万PV，数据库就会增加1GB的数据。</strong></li>
</ul>
<p>可以看到，对于高访问量的业务，需要给予 Matomo 大量的服务器资源才能支撑，具体来说，超过 30000 PV/天的业务就需要注意了。<strong>因此在业务中引入时，可以考虑业务引入上报的必要性，如果页面功能单一，操作路径少，例如展示型的 H5 页面，其实引入 Matomo 的作用不是很大。</strong></p>
<h3 id="matomo">优化 Matomo 的性能的最佳实践</h3>
<ul>
<li>Matomo 对于 PHP 的最低版本要求是 PHP5，但尽量使用 PHP7，PHP7 在性能上有大量的优化。</li>
<li>使用 PHP cache，PHP5 及以上版本默认开启了。</li>
<li>通过调整 Innodb 配置来优化 MySQL 的性能，例如增加 <code>innodb_buffer_pool_size</code> 来适应内存大小，另外可以把 <code>innodb_buffer_pool_size</code> 设置为 MySQL 可用内存的80%。增大 <code>innodb_flush_log_at_trx_commit</code> 来增加追踪器的吞吐量，具体可以参考<a href="http://dev.mysql.com/doc/refman/5.0/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit">这里</a>。</li>
<li>业务访问量比较大的时候（例如 300000 PV/天的访问量），可以关闭实时动态处理的功能（管理 - 系统 - 通用设置 - 归档设置 - 在浏览器中查看报告时进行归档 - 否），关闭实时动态处理后，页面访问数据和用户访问轨迹也需要等待 cron 任务进行数据处理后才能展示，归档时间建议是设置为 3600 秒，减轻服务端的负担。</li>
<li>对于 URL 带 Query 的情况，如果无需要区分 Query 进行数据分析的情况，可以选择忽略这些 Query（管理 - 网站 - 管理 - 编辑网站 - 排除参数），<strong>否则同一个 URL 带有不同 Query，Matomo 会当作不同的 URL 来处理，大大增加 MySQL 的负担。</strong></li>
<li>定期删除旧数据（管理 - 隐私设置 - 匿名化数据 - 定期删除旧的原始数据），旧数据的删除可以减小数据库的大小，<strong>既节省硬盘空间，也加快了数据的处理。</strong></li>
</ul>
<!--kg-card-end: markdown-->]]></content:encoded></item></channel></rss>