Kayo's Melody https://kayosite.com Practice Makes Perfect Sun, 26 Sep 2021 06:34:38 +0000 zh-CN hourly 1 https://cdn.kayosite.com/wp-content/uploads/2021/08/image.jpg?imageMogr2/thumbnail/!64x64r/gravity/Center/crop/64x64 Kayo's Melody https://kayosite.com 32 32 115630942 从 Sass Breaking Change: Slash as Division 说起 https://kayosite.com/analysis-of-sass-breaking-change-slash-as-division.html https://kayosite.com/analysis-of-sass-breaking-change-slash-as-division.html#respond Sun, 26 Sep 2021 06:32:50 +0000 https://kayosite.com/?p=3753 最近在修改一个项目的时候,发现了一系列的 Sass 的告警——由除号引起的告警:

Sass Division 告警

目录

什么告警?

告警的内容很简单,用 / 作为除法已经在 Dart Sass 2.0.0 中被弃用了,作为一个 Sass 的基础语法,这次弃用属于 breaking change 了,因此目前编译时只是会抛出 warning 而不是 error,否则大量项目都无法正常运行。

研究了一下可以看到,Sass 官方特定用了一整个篇幅的文章,来阐述为何要作出这个修改,主要的原因在于,/ 在 Sass 中同时承担除号以及 CSS 分隔符的作用,例如:

Sass 代码

// 作为除号使用
.test_division {
    border-radius: ceil(28px / 2);
}

// 作为 CSS 分隔符使用
.test_operator {
    font: 12px/1.5 -apple-system, "SF UI Text", "PingFang SC", "Lucida Grande", "Microsoft YaHei", sans-serif;
}

实际编译出的 CSS 代码

.test_division {
    border-radius: 14px;
}

.test_operator {
    font: 8px -apple-system, "SF UI Text", "PingFang SC", "Lucida Grande", "Microsoft YaHei", sans-serif;
}

示例中有两个 /,一个是用作除号,另一个是作为 font 属性中 font-sizeline-height 的分隔符,可以看到作为除号使用的时候,/ 一般不会出现什么问题,但是作为分隔符使用的时候,/ 很容易被重载为除号,实际上要实现分隔符的效果,通常需要这样编写:

.test_operator {
    font: #{12px/1.5} -apple-system, "SF UI Text", "PingFang SC", "Lucida Grande", "Microsoft YaHei", sans-serif;
}

使用了插值(Interpolation)语法,包裹了 12px/1.5,插值的作用是仅解析 Sassscript,把 Sass 变量输出为实际的值,但不会进行运算,如果属性值比较复杂,则会导致编写的时候不大直观。

Sass 本身使用了 complex heuristics 的技术去判断 / 应该作为除号还是分隔符,complex heuristics 是需要回顾当前上下文的内容,来作出判定的,因此对于 Sass 来说存在一定消耗。

综合来说,原有的 / 语法对于开发者会带来一些困扰,尤其是随着 CSS 有更多的属性使用到了分隔符(例如 gridhsl() 等语法),同时对于 Sass 的维护以及编译也会带来一些额外的消耗,所以 Sass 最终决定重新定义除法,新的语法也相当清晰:

@use "sass:math";

.test_division {
    border-radius: math.div(28px, 2);
}

新的语法基于 Sass 的 module 语法,引入了相关的运算模块后,就可以调用 math.div 代替原来的 /,而 / 则只作为分隔符使用。

如何解决告警?

要解决告警,首先要知道为何项目中会出现这个告警,用到了这个语法的项目比较多,但目前只有这个项目出现了告警。

首先这个 breaking change 仅在 Dart Sass 的最新版本中才引入,Node Sass 的版本目前还没有跟上。另外该项目中是使用 "sass": "^1.30.0" 来声明 sass 模块的版本(即 Dart Sass 的 npm 包),重新执行 npm i 会导致安装上新版的 Dart Sass 从而出现告警,因此解决方案也围绕 Dart Sass 版本去处理。

降级 sass 模块

删除 package-lock.jsonnode_modules,把 package.json 中 sass 模块的版本改为 "sass": "~1.32.12",即版本号会少于 1.33.0,这个版本的 Dart Sass 并未引入 slash as division 的 breaking change。

更新业务语法

即按上面提到的方式,把相关的告警内容改为用新的 math.div 语法代替,如果涉及的业务量比较大,更新起来会比较费时。

使用 sass-migrator

sass-migrator 是 Sass 官方推出的迁移工具,方便开发者对原有的业务代码进行最新版本的 Sass 适配。sass-migrator 并不是把相关的旧语法直接替换为最新的语法,而且采用更稳固的方式,把代码安全地更新为符合最新要求的语法,例如上面的例子:

Sass 代码

.test_division {
    border-radius: ceil(28px / 2);
}

sass-migrator 的安装与调用

npm i -g sass-migrator
sass-migrator division test.scss

处理后的 Sass 代码

.test_division {
    border-radius: ceil(28px * 0.5);
}

可以看到,sass-migrator 并没有把原有的 / 修改为最新的 math.div 语法,而是修改为用乘法代替。实际上跟 sass 基于 complex heuristics 进行分析会把分隔符重载为除号一样,迁移工具也无法准确地判断每个 / 的作用,因此 sass-migrator 采用了稳固的方式去适配最新的 Sass 规则。

Dart Sass Vs Node Sass

Node Sass 由于没有引入最新的 Sass 特性,因此并不会出现这个告警,但并不建议使用 Node Sass 代替已经用 Dart Sass 编写的代码,主要是:

  • Dart Sass 已经是官方的首选,无论是新特性还是问题修复,Dart Sass 会有更强的时效性,担心新特性会为业务带来问题可以通过锁包进行控制。
  • Node Sass 实际上已经被官方定义为 deprecated,目前项目会维护一个主要版本,但是维护进度并不确定,并且明确没有计划再为 Node Sass 添加新特性,也不会适配 CSS 的新特性。
  • Node Sass 基于 LibSass 开发,而 LibSass 依赖了的模块安装比较麻烦,尤其是对于 Windows 的用户,它要求用户在 Windows 中必须安装 Python2 和 Visual Studio。

因此出于长期维护的考虑,选用 Dart Sass 也是一个趋势。

Dart Sass on Dart-VM 与 Dart Sass on NPM

目前 Dart Sass 有两种实现,分别是:

  • 基于 Dart-VM 的 Dart Sass
  • 基于纯 JavaScript 的 Dart Sass

根据官方的介绍,单独运行的命令行版本,是基于 Dart-VM 运行的,得益于 Dart-VM 的高性能,这个版本的 Dart Sass 性能非常好,适合用于编写脚本单独编译 Sass 文件。

而 NPM 中的 Dart Sass 则是纯 JavaScript 实现,因此可以很方便地用于前端项目构建。虽然 JavaScript 版本的 Dart Sass 性能比 LibSass 要差一些,但是对于样式代码的编译量来说,区别并不大。

三个版本的 Sass 编译速度对比

上图是 Dart Sass on Dart-VM、Dart Sass on NPM、Node Sass 分别去编译 BootStrap 4 的耗时(来源于 Stack Overflow),可以看出 Dart Sass on NPM(即 Dart Sass JS)比 Node Sass 还要慢很多(大概3倍的耗时),但实际上即使是 Bootstrap 这个体量,也只是2秒的耗时,考虑到官方和社区都逐步迁移到 Dart Sass 了,因此新项目也建议使用 Dart Sass。

]]>
https://kayosite.com/analysis-of-sass-breaking-change-slash-as-division.html/feed 0 3753
Matomo 从了解到落地——页面流量统计与分析最佳实践 https://kayosite.com/matomo-best-practices.html https://kayosite.com/matomo-best-practices.html#comments Wed, 07 Apr 2021 08:29:00 +0000 https://kayosite.com/?p=3585 背景

在开发面向内部使用的「内容管理平台」的过程中,我们不时会收到一些页面问题的反馈,但在本地调试的过程中,有大量无法在本地重现的问题,这些问题的出现跟用户的访问设备、网络环境、访问路径可能存在关联。为了方便快捷地去定位这些问题,我们试图为所有页面点击操作都加上打点记录,但在实际操作中,由于业务变更频繁,开发框架的限制,展示打点数据较为复杂等因素,通过打点排查问题的实际效果并不理想,因此我们希望引入完整的流量统计和用户行为分析来定位问题。

不同的方案分析对比

对于流量统计和用户行为分析记录的工具,行业内已经有大量成熟的解决方案,相对于自行打点,这些专门的流量通过平台和工具对于业务的基本没有侵入性,也解决了如何展示数据的问题。这些平台和工具中,有著名的 Google Analytics、百度统计、WebTrends 等,也有相对冷门的今天的主角 —— Matomo,而这些方案之间各有优劣:

解决方案/平台优势劣势
Google Analytics部署简单,只需在页面加入 JS 追踪器代码,数据分析快(小时级别),功能强大,分析维度丰富数据量大的时候偶尔会丢失数据,无法定制化
Adobe Analytics数据展示清晰明了,功能强大部署复杂,只有付费版本,技术支持和文档都较少
WebTrends数据分析维度丰富,报告全面,监控过程安全主要针对大客户,费用非常高
CNZZ部署和接入简单,分析功能易用,报告简洁没有用户细分数据,也不支持用户路径分析,功能较为单一
Matomo对标 Google Analytics 的功能,接入简单,功能强大,分析维度丰富,支持私有化部署,包括代码和数据都可以私有化处理,有强大的插件机制,可以自行开发功能私有化需要自行部署和维护服务器、数据库等,部分分析功能需要二次开发

通过对比,Matomo 整体功能比较强大,对标了 Google Analytics 但在安全性和私密性方面更优,支持私有化部署,代码和数据都可以不透露给第三方,并且可以通过插件的机制配合业务实现自定义,这些优点都是我们最终选择 Matomo 作为「内容管理平台」用户记录的工具的原因。

Matomo 是什么?

这里介绍一下 Matomo,作为一套基于 PHP 与 MySQL 的网页流量统计和分析平台,它的大部分功能已经开源,并且做了很好的封装,可以轻松地进行私有化部署,它的功能主要分成两块:

  • 收集并存储页面访问数据,主要是用户信息,如设备型号、分辨率、用户地区、来源,以及页面信息,如页面访问路径、访问操作等。
  • 对收集起来的数据进行指标量化并可视化的展示,例如用户设备型号分布、地区分布、某个页面的浏览人数、访问最多的页面、某个用户在某个页面的访问路径和具体操作等,并且在收集数据时,Matomo 会有大量的策略保护用户隐私,例如上报 IP 时隐藏最后一位字节等。

在实际使用时,用户信息的上报以及页面的访问路径,只需要安装并引入 Matomo 即可实现,无需额外的配置。但是开发者可以通过接口增强上报的数据,例如上报某个弹窗的展示,或者上报某个请求的结果,这样最终可以在平台上展示出完整的用户访问路径和操作,结合业务日志,可以很准确地定位问题以及还原问题的触发路径。

Matomo 落地到业务

在引入 Matomo 之前,先说明一下 Matomo 的主要组成追踪器和 Matomo 服务端,追踪器基于 JS 实现,需要在网页引入,用于上报数据。服务端主要提供了三个功能:

  • HTTP 接口,追踪器可以收集所在网页的数据但不上报,通过 HTTP 接口发送给 Matomo。
  • 归档任务运行并预处理数据,默认分为实时动态处理(页面访问数据,用户访问轨迹)和 cron 任务处理(用户维度的列表)。
  • 可视化展现数据,也可以数据接口或者报表接口来访问这些数据。

引入简单落地不易

Matomo 有很成熟封装,因此本身部署很简单,主要分为两个步骤:

  1. 部署私有化 Matomo 服务。
  2. 在需要流量统计ide页面上引入追踪器。

其中部署私有化服务只需要下载 Matomo 的程序并上传到服务端,然后打开访问地址就可以使用引导程序部署服务,包括检测服务器环境是否符合要求,填写数据库信息,创建管理账号等,具体参考官方文档

但在实际落地到内容平台的过程中,却遇到了问题——我们需要基于 Docker 进行部署。

由于业务的部署都基于 Docker 和 k8s 进行,因此私有化的 Matomo 也需要基于此进行部署,这样会带来几个问题:

  1. Matomo 的设置分成系统配置与功能设置,其中功能设置储存在 MySQL 中,而系统设置则储存在本地的配置文件中,当部署多个容器时,配置无法对齐,另外 Docker 重新部署后,这些配置修改也会丢失。
  2. 这套部署需要域名 + 路径的形式访问 Matomo,Matomo 社区镜像中是使用 Apache2 进行路由处理的,而 Apache2 默认的配置并不适配路径,需要修改 Apache 的配置文件。

解决在 Docker 中部署 Matomo 的问题

Matomo 有官方发布的社区镜像可以直接使用,但为了解决上述的问题,需要在构建 Docker 镜像时进行额外的处理。

解决配置丢失的问题

Matomo 的配置文件是 config/config.ini.php,不跟随版本管理,为了获取一份默认的配置文件,可以用社区镜像预先部署好一个 Matomo 容器,并在容器中获取一份默认的配置文件,例如:

[database]
host = "${MATOMO_DATABASE_HOST}"
username = "${MATOMO_DATABASE_USERNAME}"
password = "${MATOMO_DATABASE_PASSWORD}"
dbname = "${MATOMO_DATABASE_DBNAME}"
tables_prefix = "${MATOMO_DATABASE_TABLES_PREFIX}"
charset = "utf8mb4"
multi_server_environment = 1
enable_installer = 0

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

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

[PluginsInstalled]
PluginsInstalled[] = "Diagnostics"
// ...
// 所有插件列表,,由于篇幅有限,省略默认的插件列表

复制出默认的配置文件后,即可根据业务进行修改,主要包括:

  1. 数据库的配置,建议使用环境变量进行配置。
  2. salt 是用于解密配置内容的加密串,保留默认配置中的值即可。
  3. trusted_hosts[] 是部署 Matomo 的域名,支持多个域名配置,必须正确填写,否则无法使用。
  4. Plugins[]PluginsInstalled[] 分别是需要启用的插件和总插件列表,有需要调整插件的激活状态可以自行调整。

在修改完成后,可以利用 Docker 的命令把自定义的配置文件覆盖到镜像中,例如:

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

解决子目录部署的问题

Matomo 部署完成后,会以 weread.qq.com/weread-matomo 的形式去访问 Matomo 的服务,因此根据默认的 Apache2 配置,会尝试在 weread-matomo 这个目录中读取 Matomo,但实际上我们的 Matomo 是部署在根目录的,因此需要修改 Apache2 的配置文件,把针对 ^/weread-matomo 的访问指向根目录。

值得注意的是,出于安全考虑,我们不希望把 Matomo 的管理后台暴露到外网,因此在 Apache2 的配置中,可以通过正则指定只有追踪器相关的文件暴露到外网可以访问,方便业务引入。在了解了 Matomo 的源码后,追踪器相关的文件主要有 matomo.jsmatomo.php,其中 matomo.php 结尾会带有参数,因此最终的 AliasMatch 规则如下:

<VirtualHost *:80>
    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

    <Directory /var/www/html>
        Options All
        AllowOverride All
        order allow,deny
        allow from all
    </Directory>
</VirtualHost>

社区镜像中 Apache2 的配置文件存放在 /etc/apache2/sites-available/000-default.conf,把上面的配置内容在本地保存一份 000-default.conf 后,在构建 Docker 镜像时利用命令覆盖默认的 Apache2 配置:

COPY 000-default.conf /etc/apache2/sites-available/000-default.conf

无法显示城市信息

在解决了上面两个问题后,Matomo 的私有化部署基本已经跑通了,但后续我们发现,这样上报的数据中,并没有显示城市信息,Matomo 是基于 IP 信息来判定城市的,而 Matomo 自带的 IP 库仅能识别国家信息。

访客地图

如上图所示,城市都显示为“未知”。为了解决这个问题,需要引入 IP 地址库,Matomo 支持 DBIP 和 GeoIP 2 两个外部的地址库,地址库的格式都是一种特殊的地址库 .mmdb 格式。

这里建议使用 GeoIP,在这里完成注册后,可以下载 .mmdb 格式的地址库。为了让 Matomo 识别出额外的地址库,需要把 .mmdb 放置到项目的 misc 目录,但由于 Matomo 使用了 Docker 部署,因此需要用 Docker 命令把 .mmdb 文件复制到容器的 misc 目录,最终完整的 Dockerfile 如下:

# 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

前端引入追踪器也有坑

页面引入追踪器

经过上面的处理,已经解决了在 Docker 中部署服务端的问题,在 Matomo 的部署引导程序在完成后,会输出一段 JS 代码,用于给业务前端引入追踪器,例如:

<!-- Matomo -->
<script type="text/javascript">
  var _paq = window._paq = window._paq || [];
  /* tracker methods like "setCustomDimension" should be called before "trackPageView" */
  _paq.push(['trackPageView']);
  _paq.push(['enableLinkTracking']);
  (function() {
    var u="//xxxx"; // 私有化部署 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);
  })();
</script>
<!-- End Matomo Code -->

Matomo 的追踪器包含了大量的选项和方法,主要包括:

  1. Tracker Object,用于记录某个行为,例如上面代码中的 trackPageView 则用于记录某个页面被访问,enableLinkTracking 则是用于开启链接跳转时自动记录的功能。
  2. Configuration of the Tracker Object,用于配置 Tracker Object,例如 setDocumentTitle 可以覆盖上报页面的标题,默认是获取 document.title
  3. Ecommerce,电商相关的方法,提供了一系列记录商品信息的方法。
  4. Managing Consent,提供了一种机制来管理用户的跟踪上报。

具体可以参考文档

自动记录 Vue SPA 的页面跳转

成功引入追踪器后发现,「内容管理平台」上报的用户行为,只有打开页面的操作,跳转页面并没有成功上报,但是默认的追踪器代码中,已经开启了 `` 选项。

在 Matomo 的源码中,可以看到对 `` 的说明:

 // @param bool enable Defaults to true.
 //    If "true", 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 "Open in new tab" is not selected.
 //      If "false" (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;
    // ...
};

也就是说,这个选项仅对 link,也就是常见的 <a href="xxxx">链接</a> 这种形式的跳转才起作用,而「内容管理平台」是基于 Vue 开发的 Spa,页面跳转不是链接跳转,因此上报的记录里只有打开页面。

要解决这个问题,可以在 Vue 进行跳转时主动调用 Matomo 的上报,但实际上已经有开源的插件实现了这个,例如vue-matomo,具体使用可以参考它的使用文档

值得注意的是,vue-matomo 对 matomo 的初始参数进行封装,除了文档中列出来的选项,其他选项在初始化的时候是无效的,可以在 vue-matomo 初始化后,通过 _paq.push(['xxx']) 调用,_paq 对象的 push 方法已经被重写,调用 push 方法实际上相当于把某个方法放入调用队列并进行调用。

Matomo 的最佳实践

经过上面的踩坑和填坑后,Matomo 最终得以在「内容管理平台」中落地投入使用,在经过一段时间的实践后,现有的自动记录还是不能满足我们的需求,例如我们需要自动上报 JS 错误信息,在点击 UI 元素时也需要上报,另外还需要在请求错误时进行自动上报。在经过一系列实践后,总结了一些最佳实践。

自动记录 JS 错误

在新版 Matomo 中,支持开启自动上报 JS 错误的功能,但功能尚未正式发布,因此官方文档中没有该功能的说明,需要调用的话可以通过 window._paq.push(['enableJSErrorTracking']); 开启该功能,为了保护 _paq 没有初始化好的情况,可以先判断 _paq 是否存在,例如:

const enableJSErrorTracking = (): void => {
  if (window._paq) {
    window._paq.push(['enableJSErrorTracking']);
  } else {
    console.warn('can not found window._paq');
  }
};

主动上报更多操作

除了链接跳转,页面中通常还会有一些 UI 操作不涉及链接变化,也不涉及请求,这类操作可以使用追踪器提供的 Tracker Object 进行主动上报,为了方便起见,可以抽取成工具方法,例如:

// 上报一个事件,例如点击事件,播放事件等,在主动上报中比较常用。
export const trackEvent = (category: string, action: string, name?: string, value?: number): void => {
  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 => {
  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 => {
  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 => {
  if (window._paq) {
    window._paq.push(['trackSiteSearch', keyword, category, resultsCount]);
  } else {
    console.warn('can not found window._paq');
  }
};

请求失败自动上报

业务中涉及请求的操作通常都比较关键,请求失败自动上报有利于记录下完整的用户动作路径,方便定位问题,在我们的业务中,我们的请求都是使用 Axios 发出的,因此可以利用 axios interceptors 劫持所有请求,在遇到指定错误时自动上报到 Matomo,例如:

const baseURL = 'xxx';
// axios instance
const service = axios.create({
  baseURL,
  timeout: 60000,
});

service.interceptors.response.use(
  (response: AxiosResponse) => {
    const errCode = response.data && (response.data.errCode || response.data.errcode);
    if (errCode && errCode < 0) {
      const URL = response.config && response.config.url;
      const errMsg = response.data && (response.data.errMsg || response.data.errMsg);
      trackErrorEvent(URL, errMsg, errCode);
    }
    return response;
  },
  (error) => {
    return Promise.reject(error);
  }
);

至此,已经可以很准确展示用户在访问页面时的完整操作路径了,开发者可以通过这些操作路径,结合业务日志,方便地去定位问题以及还原问题。

完整记录用户操作的示意图

效果展示

经过以上的处理,现在已经可以上报非常丰富的访问数据,以及用户路径了,例如:

访客分析 - 访问日志

访问日志

可以看到,界面上显示了完整的用户操作,通过时间轴的形式,配合不同的关键词和 icon 可以很好地呈现出实际的操作路径。

访客分析 - 设备

访客分析设备列表

除了设备,在 Matomo 中还有地区等用户维度,并且 Matomo 在不同的数据展示中,例如目标转化率等,都可以基于这些不同的维度进行展示,对于分析用户组成相当方便。

转化与收益分析 - 概览

Matomo 支持设定指定的目标,用于计算转化率,并进行多个维度的展示,包括转化流向,每个阶段的转化人数和转化率等,并且可以通过不同的维度,例如渠道类型、城市、设备类型分别展示各种维度下的转化数据,这也是 Matomo 一种重要的特性,数据的展示维度丰富。

转化与收益分析

另外,Matomo 的插件机制也非常强大,可以插入自定义的数据,注入到各个界面或者基于 Matomo 自身收集的数据重新展示,基于篇幅所限,后续再对 Matomo 的插件机制进行实践说明。

Matomo 的性能分析与局限性

从上面的说明中可以看出,Matomo 的分析功能强大,分析的维度也很丰富,但同时也带来了较大的服务端资源消耗。

Matomo 的架构可以支撑千万级甚至亿级的月 PV,但同时对于服务端的 CPU,RAM 和硬盘空间都有相应的要求。因此在实际使用时,需要注意当前服务端的配置是否足以支撑上报页面的 PV 量,否则会导致 Matomo 无法及时处理数据甚至崩溃。

量化性能分析

  • Matomo 的默认配置是 1GB 内存,在默认配置下,Matomo 可以轻松支撑 1000 PV/天的访问量,对于这种级别的访问量,通常是一些内部平台或者面向特定人群的辅助页面。
  • 对于 3000 PV/天访问量的业务,则建议使用2核 CPU,2GB RAM,50GB 硬盘的配置。
  • 对于 30000 PV/天访问量的业务,则建议使用4核 CPU,8GB RAM,250GB 硬盘的配置,这个量级的访问可以是面向大众用户的业务页面了。
  • 对于 300000 PV/天访问量的业务,建议把 PHP 服务端和 MySQL 分开部署,对于这种量级的业务,MySQL 的瓶颈会更加明显,把 MySQL 部署进行单独部署,会更加稳定,建议最低的配置是8核 CPU,16GB RAM, 100GB 硬盘的机器作为 PHP 服务端,8核 CPU, 16GB RAM, 400GB 硬盘作为 MySQL 服务,
  • 对于更高访问量的业务,可以再叠加机器配置,硬盘空间主页是给 MySQL 消耗用的,一个参考数据是:大概每增加500万PV,数据库就会增加1GB的数据。

可以看到,对于高访问量的业务,需要给予 Matomo 大量的服务器资源才能支撑,具体来说,超过 30000 PV/天的业务就需要注意了。因此在业务中引入时,可以考虑业务引入上报的必要性,如果页面功能单一,操作路径少,例如展示型的 H5 页面,其实引入 Matomo 的作用不是很大。

优化 Matomo 的性能的最佳实践

  • Matomo 对于 PHP 的最低版本要求是 PHP5,但尽量使用 PHP7,PHP7 在性能上有大量的优化。
  • 使用 PHP cache,PHP5 及以上版本默认开启了。
  • 通过调整 Innodb 配置来优化 MySQL 的性能,例如增加 innodb_buffer_pool_size 来适应内存大小,另外可以把 innodb_buffer_pool_size 设置为 MySQL 可用内存的80%。增大 innodb_flush_log_at_trx_commit 来增加追踪器的吞吐量,具体可以参考这里
  • 业务访问量比较大的时候(例如 300000 PV/天的访问量),可以关闭实时动态处理的功能(管理 - 系统 - 通用设置 - 归档设置 - 在浏览器中查看报告时进行归档 - 否),关闭实时动态处理后,页面访问数据和用户访问轨迹也需要等待 cron 任务进行数据处理后才能展示,归档时间建议是设置为 3600 秒,减轻服务端的负担。
  • 对于 URL 带 Query 的情况,如果无需要区分 Query 进行数据分析的情况,可以选择忽略这些 Query(管理 - 网站 - 管理 - 编辑网站 - 排除参数),否则同一个 URL 带有不同 Query,Matomo 会当作不同的 URL 来处理,大大增加 MySQL 的负担。
  • 定期删除旧数据(管理 - 隐私设置 - 匿名化数据 - 定期删除旧的原始数据),旧数据的删除可以减小数据库的大小,既节省硬盘空间,也加快了数据的处理。

]]>
https://kayosite.com/matomo-best-practices.html/feed 4 3585
探索巴伐利亚——中世纪世界的记录 https://kayosite.com/10-days-bavaria-and-austria-road-trip.html https://kayosite.com/10-days-bavaria-and-austria-road-trip.html#respond Wed, 10 Jun 2020 12:47:00 +0000 https://kayosite.com/?p=3709 去年夏天这个时候,广州又是每年的雨季,经过14个小时的颠簸飞往了法兰克福,然后就根据之前设计的环线路程,自驾从法兰克福开始,一路探索了巴伐利亚地区,从南部再去到奥地利,再从奥利地中部回到法兰克福。

对于巴伐利亚的印象,基本停留在《世界通史》里介绍的模样——遍地的石板路,沧桑但坚实,多年的磨砺使得路面高低不平,城市里遍布城堡,同样古老但仍旧不倒。

来都来了,对于中世纪历史感兴趣的我,自然要把看到的记录下来。

]]>
https://kayosite.com/10-days-bavaria-and-austria-road-trip.html/feed 0 3709
Web 可访问性与无障碍最佳实践 https://kayosite.com/web-accessibility-best-practice.html https://kayosite.com/web-accessibility-best-practice.html#respond Thu, 07 Mar 2019 12:33:00 +0000 https://kayosite.com/?p=3565 Web 无障碍开发知识

对于 Web 开发者来说,可以通过调整 HTML 的结构和标签,增加 HTML 属性,配合 CSS 和 JavaScript 等手段来提高页面的可访问性和无障碍性。例如使用了 a 标签制作了按钮,如果不进行额外的优化,读屏软件在朗读时会读作"文字内容 链接",但实际上该 a 标签是用作按钮使用,因此可以在标签上添加 role="button" 属性。此时,读屏软件会读作"文字内容 按钮"。

可以看到,读屏软件在朗读时会在结尾朗读出元素的属性,这也是无障碍优化中重要的一环。无障碍优化就是要解决如何使得元素的属性被正确识别,如何使得元素的内容被清晰准确地朗读,如何排除干扰元素等问题。在了解 WeRead H5 的无障碍优化处理之前,首先需要了解 Web 无障碍开发的基础知识,及读屏软件的工作方式(以 Apple VoiceOver 为例),可以参考以下资料:

WeRead H5 的开发细则

我们参考 WCAG 2.1,在微信读书 H5 项目中总结了一些无障碍开发的最佳实践。

DOM 的顺序很重要

读屏软件在读屏时默认按照 DOM 的顺序朗读,因此如果 DOM 的顺序与内容的语义顺序不一致,例如使用了 flex-direction: row-reverse; 使得内容的顺序倒序显示,会使得内容难以理解。因此尽量避免使用会影响到 DOM 视觉顺序的样式,如果无法避免,需要手动设置 tabIndex 属性,告知读屏软件正确的内容顺序。

为非文本元素提供文本说明

图像使用 alt 属性描述图像内容

<img> 标签需要加上 alt 属性,读屏软件会自动读出 alt 的内容,例如 alt 内容为"一只目光汹汹凝视远方的猫",那么会被读作"一只目光汹汹凝视远方的猫 图像"。如果没有添加 alt 属性,那么仅会读作"图像",视障用户会完全无法理解其实际含义。

但是,当 <img> 标签出现在 <a> 标签内部,作为一个图像链接时,应在 <a> 上使用 title 属性,<img> 标签可不加 alt 属性。

视频使用 title 属性

与上面的 <img> 标签相似,<video> 标签需要加上 title 属性,例如 title 内容为"一只正在奔跑的猫",那么会被读作一只正在奔跑的猫 视频"。

使用语义化的元素

尽量使用语义化标签

语义化的 HTML 标签,例如 <header> <footer> <nav> <section> <main> <aside> <button>,使用语义化的标签,主要影响两个方面:

  • 选中元素时是否会整块选中
  • 朗读时结尾会加上怎样的修饰词

其中默认设置下,目前仅 <button> 标签可以使得选中元素时会整块选中,而不单独选中子元素。至于修饰词这里列举具体的情况:

  • <header> 读作"xxx 横幅 标志性内容"。
  • <footer> 读作"xxx 页脚 标志性内容"。
  • <nav> 读作"xxx 导航 标志性内容"。
  • <section> 仅读作"xxx",没有结尾修饰词。
  • <main> 读作"xxx 主要 标志性内容"。
  • <aside> 读作"xxx 补充 标志性内容"。
  • <button> 读作"xxx 按钮"。
  • <a> 读作"xxx 链接"。

实际上,在浏览器内部,使用语义化标签会隐式加上特定的 role 属性,最后朗读时的结尾修饰词也正是这些 role 属性的值以及分类,其他 role 的值朗读时也可以以此类推,而以上标签与 role 属性具体对应的关系如下:

HTML 标签role 属性值
headerbanner
footercontentinfo
navnavigation
sectionregion
mainmain
asidecomplementary
buttonbutton
alink

以上 role 属性值的分类大多数都属于标志性内容

role 属性

如果出于其他考虑,使用了非对应语义的标签,例如开头提到的使用 a 标签实现按钮,就需要添加 role="button" 属性来声明这是一个按钮。同理,其他类似情况也可以这样处理,主要的就是影响朗读时的修饰词。

禁用状态使用 disabled 属性

使用特定的 class 来增加禁用态样式是常见的手法,但由于 class 语义并不能被读屏软件识别,因此读屏时无法知道当前处于禁用态。可以改为使用 disabled 属性实现禁用态,例如:

<input type="search" name="q" placeholder="请输入用户名" aria-label="搜索用户" disabled/>
/* 禁用态样式 */
input[disabled] {
    opacity: .5;
}

会读作 搜索用户 请输入用户名 变暗 搜索栏,读屏软件会用"变暗"这个词表示搜索栏处于不可用的状态。而对于没有 disabled 属性的标签,例如 a 标签,可以使用 aria-disabled 属性达到同样的效果。

可使用 aria 标签向不存在原生语义的元素添加语义

  • aria-label="screen reader only label",用于添加朗读时的描述,读屏时会读出其中的内容,而忽略标签的原有的文字,例如为 a 标签同时添加 role="button"aria-label="额外的按钮描述",最终会朗读成"额外的按钮描述 按钮"。
  • aria-controls="main",用于给操作按钮关联控制区域,VoiceOver 上这个属性没有任何作用,但 PC 读屏软件中,添加了该属性后,可以把焦点从按钮快速移动到被控制区域。
  • aria-live="true",添加了该属性的元素,在其内容发送变化时,读屏软件会自动读出变化后的新内容。可以用于会动态刷新的元素,例如发现卡片上的“XXX人参与活动”,书城的换一批功能,用于监听实时变化的数据。实际效果可以参考这个 demo
  • 更多属性详见:https://www.w3.org/TR/wai-aria/

动画

可在 iOS 下通过 CSS 选择器 @media(prefers-reduced-motion) 来针对开启了“避免动画”的用户取消动画。

隐藏屏幕外的元素

确保屏幕外的内容已通过 display: nonevisibility: hidden 隐藏(如浮动出现的 alert 和 banner 等),如没有隐藏,读屏软件仍会读出元素内容,但屏幕外的元素通常不希望被读出,如果不方便使用样式进行隐藏,可以为元素添加 aria-hidden="true" 属性,元素则会被读屏软件忽略。

常用场景

图像的编写

如上文所述,图像需要补充文字描述,补充时需要使用具体的内容标题,例如书籍封面,可以使用书籍名称,而不要直接统一描述为"书籍封面",同理用户头像也应该使用用户名作为描述文字。

按钮的编写

在 H5 中,为了避免一些浏览器默认样式的干扰,以及制作点击效果(具体原因),目前采用 a 标签实现。但从无障碍的角度考虑,a 标签默认会被当做链接处理,读屏时会读作"链接内的文字 链接"。

基础无障碍适配

需要加上 aria="button" 属性,例如:

<a class="test_btn" role="button" href="javascript:;">文字</a>

读屏时会读出"文字 按钮"。

增加描述文字

如果 a 标签内本身没有文字,例如以图片、背景色和边框制作的按钮,还需要加上 aria-label="描述文字",读屏时会读作"描述文字 按钮"的形式。当 a 标签内的文字对于视障人士不足以描述清楚按钮作用时(例如需要结合上下的元素,或者结合按钮本身的背景图才能理解按钮的含义时),也可以加上 aria-label 属性,aria-label 的内容会被优先读出,例如:

<a class="test_btn" role="button" href="javascript:;" aria-label="更完整的描述">文字</a>

多重标签嵌套

另外 a 标签内容如果有嵌套的标签,并不会影响文字被读出,例如:

<a class="test_btn" role="button" href="javascript:;">
    <span class="test_btn_inner">
        <span class="test_btn_inner_text">文字</span>
    </span>
</a>

读屏时仍会读出"文字 按钮"。

整块可点击元素的编写

在遇到 banner 等本身由多个子元素组成,但点击时为整块点击的元素,需要分为两种情况考虑:

使用 a 标签实现

如果使用了默认的点击效果,即使用了 a 标签实现外层框,读屏时子元素会被分别选中,但实际上单独读出每个子元素不能表达按钮整体的完整含义。因为,我们建议整块当作按钮处理,但一般无需添加 aria-label,让读屏软件直接按 DOM 顺序读出子元素的文字内容即可,例如:

<a class="welcomeBonus_packet" href="javascript:;" role="button" @click="packetRedeem">
    <div class="welcomeBonus_packet_info">
        <div class="welcomeBonus_packet_info_title">主标题内容文字</div>
        <div class="welcomeBonus_packet_info_desc">描述文字</div>
    </div>
    <div class="welcomeBonus_packet_btn">
        <span>提示文字</span>
    </div>
</a>

会被读作"主标题内容文字 描述文字 提示文字 按钮",视障人士会清楚这是整体点击的按钮,并且了解到其作用。如果部分内容不希望被读出来,精简朗读文案的时长,例如作用不大的辅助语句,可以单独添加 aria-hidden="true"

需要注意,可点击元素点击后跳转页面通常采用 role="link" 声明,而点击后进行一些操作则通常采用 role="button" 声明,读屏的时候结尾分别为"链接"和"按钮",但本场景下建议统一使用 role="button",因为 role="link" 并不会让元素整块被识别,实际体验上,整体识别能带来更好的体验,而视障人士对于"链接"和"按钮"的理解包容度也比较高。

使用语义化标签实现

如果无需使用默认的点击效果,建议使用语义化的标签实现外层框,例如 sectionaside,这样用户在使用“container 模式”进行读屏时,元素会直接被整体识别,而不会单独读出子元素。

以 VoiceOver 为例,双指旋转可以调节焦点选择的模式,”container 模式“下焦点仅会被 section 这类外层容器捕捉。

小程序注意事项

  • 小程序中目前仅支持 aria-role(相当于原生 Web 的 role)和 aria-label 两个属性,如果读屏时需要忽略某些元素,无法使用 aria-hidden 来声明,因此需要注意尽量让无需被读屏的元素不输出 DOM。
  • 小程序中没有语义标签,因此整块点击的元素只能加上 aria-role="button"

]]>
https://kayosite.com/web-accessibility-best-practice.html/feed 0 3565
Android Lint 实践之二 —— 自定义 Lint https://kayosite.com/android-lint-custom-issue.html https://kayosite.com/android-lint-custom-issue.html#respond Mon, 16 Oct 2017 05:29:00 +0000 https://kayosite.com/?p=3562 背景

如前文《Android Lint 实践 —— 简介及常见问题分析》所述,为保证代码质量,团队在开发过程中引入了 代码扫描工具 Android Lint,通过对代码进行静态分析,帮助发现代码质量问题和提出改进建议。Android Lint 针对 Android 项目和 Java 语法已经封装好大量的 Lint 规则(issue),但在实际使用中,每个团队因不同的编码规范和功能侧重,可能仍需一些额外的规则,基于这些考虑,我们研究并开发了自定义的 Lint 规则。

基础

创建自定义 Lint 需要创建一个纯 Java 项目,引入相关的包后可以基于 Android Lint 提供的基础类编写规则,最终把项目以 jar 的形式输出后就可以被主项目引用。这里我们以 QMUI Android 中的一个实际场景来说明如何进行自定义 Lint:我们在项目中使用了 Vector Drawable,在 Android 5.0 以下版本的系统中,Vector Drawable 不被直接支持,这时使用 ContextCompat.getDrawable() 去获取一个 Vector Drawable 会导致 crash,而这种情况由于只在 5.0 以下的系统中才会发生,往往不易被发现,因此我们需要在编写代码的阶段就能及时发现并作出提醒。在 QMUI Android 中,提供了 QMUIDrawableHelper.getVectorDrawable 方法,基于 support 包封装了安全的获取 Vector Drawable 的方法,因此我们最终的需求是检查出所有使用 ContextCompat.getDrawable()getResources().getDrawable() 去获取 Vector Drawable 的地方,进行提醒并要求替换为 QMUIDrawableHelper.getVectorDrawable 方法。

创建工程

如上面所述,创建自定义 Lint 需要创建一个 Java 项目,项目中需要引入 Android Lint 的包,项目的 build.gradle 如下:

apply plugin: 'java'

configurations {
    lintChecks
}

dependencies {
    compile "com.android.tools.lint:lint-api:25.1.2"
    compile "com.android.tools.lint:lint-checks:25.1.2"

    lintChecks files(jar)
}

jar {
    manifest {
        attributes('Lint-Registry': 'com.qmuiteam.qmui.lint.QMUIIssueRegistry')
    }
}

其中 lint-api 是 Android Lint 的官方接口,基于这些接口可以获取源代码信息,从而进行分析,lint-checks 是官方已有的检查规则。Lint-Registry 表示给自定义规则注册,以及打包为 jar,这个下面会详细解释。

Detector

Detector 是自定义规则的核心,它的作用是扫描代码,从而获取代码中的各种信息,然后基于这些信息进行提醒和报告,在本场景中,我们需要扫描 Java 代码,找到 getDrawable 方法的调用,然后分析其中传入的 Drawable 是否为 Vector Drawable,如果是则需要进行报告,完整代码如下:

/**
 * 检测是否在 getDrawable 方法中传入了 Vector Drawable,在 4.0 及以下版本的系统中会导致 Crash
 * Created by Kayo on 2017/8/24.
 */

public class QMUIJavaVectorDrawableDetector extends Detector implements Detector.JavaScanner {

    public static final Issue ISSUE_JAVA_VECTOR_DRAWABLE =
            Issue.create("QMUIGetVectorDrawableWithWrongFunction",
                    "Should use the corresponding method to get vector drawable.",
                    "Using the normal method to get the vector drawable will cause a crash on Android versions below 4.0",
                    Category.ICONS, 2, Severity.ERROR,
                    new Implementation(QMUIJavaVectorDrawableDetector.class, Scope.JAVA_FILE_SCOPE));

    @Override
    public List<String> getApplicableMethodNames() {
        return Collections.singletonList("getDrawable");
    }

    @Override
    public void visitMethod(@NonNull JavaContext context, AstVisitor visitor, @NonNull MethodInvocation node) {

        StrictListAccessor<Expression, MethodInvocation> args = node.astArguments();
        if (args.isEmpty()) {
            return;
        }

        Project project = context.getProject();
        List<File> resourceFolder = project.getResourceFolders();
        if (resourceFolder.isEmpty()) {
            return;
        }

        String resourcePath = resourceFolder.get(0).getAbsolutePath();
        for (Expression expression : args) {
            String input = expression.toString();
            if (input != null && input.contains("R.drawable")) {
                // 找出 drawable 相关的参数

                // 获取 drawable 名字
                String drawableName = input.replace("R.drawable.", "");
                try {
                    // 若 drawable 为 Vector Drawable,则文件后缀为 xml,根据 resource 路径,drawable 名字,文件后缀拼接出完整路径
                    FileInputStream fileInputStream = new FileInputStream(resourcePath + "/drawable/" + drawableName + ".xml");
                    BufferedReader reader = new BufferedReader(new InputStreamReader(fileInputStream));
                    String line = reader.readLine();
                    if (line.contains("vector")) {
                        // 若文件存在,并且包含首行包含 vector,则为 Vector Drawable,抛出警告
                        context.report(ISSUE_JAVA_VECTOR_DRAWABLE, node, context.getLocation(node), expression.toString() + " 为 Vector Drawable,请使用 getVectorDrawable 方法获取,避免 4.0 及以下版本的系统产生 Crash");
                    }
                    fileInputStream.close();
                } catch (Exception ignored) {
                }
            }
        }
    }
}

QMUIJavaVectorDrawableDetector 继承于 Detector,并实现了 Detector.JavaScanner 接口,实现什么接口取决于自定义 Lint 需要扫描什么内容,以及希望从扫描的内容中获取何种信息。Android Lint 提供了大量不同范围的 Detector

  • Detector.BinaryResourceScanner 针对二进制资源,例如 res/raw 等目录下的各种 Bitmap
  • Detector.ClassScanner 相对于 Detector.JavaScanner,更针对于类进行扫描,可以获取类的各种信息
  • Detector.GradleScanner 针对 Gradle 进行扫描
  • Detector.JavaScanner 针对 Java 代码进行扫描
  • Detector.ResourceFolderScanner 针对资源目录进行扫描,只会扫描目录本身
  • Detector.XmlScanner 针对 xml 文件进行扫描
  • Detector.OtherFileScanner 用于除上面6种情况外的其他文件

不同的接口定义了各种方法,实现自定义 Lint 实际上就是实现 Detector 中的各种方法,在上面的例子中,getApplicableMethodNames 的返回值指定了需要被检查的方法,visitMethod 则可以接收检查到的方法对应的信息,这个方法包含三个参数,其作用分别是:

  • context 这里的 context 是一个 JavaContext,主要的功能是获取主项目的信息,以及进行报告(包括获取需要被报告的代码的位置等)。
  • visitor visitor 是一个 ASTVisitor,即 AST(抽象语法树)的访问者类,Android Lint 把扫描到的代码抽象成 AST,方便开发者以节点 - 属性的形式获取信息,visitor 则可以方便地获取当前节点的相关节点。
  • node 这是一个 MethodInvocation 实例,MethodInvocation 是 Android Lint 里的 AST 子类,在上面的例子中,node 表示的是被扫描到的方法,所以我们可以通过节点 - 属性的形式获取被扫描的方法的参数等各种信息。

在例子中我们获取方法的参数,通过遍历参数拿到 Drawable 参数,分解出 Drawable 的文件名,然后通过 context 获取主项目的资源路径,配合 Drawable 的文件名拼接文件的实际路径,确定文件存在后检查文件内容开头是否包含 “vector” 这个字符串,如果是则表示开发者在普通的 getDrawable 方法中传入了 Vector Drawable,最后调用 context 的 report 方法进行报告。

值得注意的是,在例子中我们并没有直接实例 Drawable,然后通过 Drawable 的方法判断是否为 Vector Drawable,而是通过较为繁琐的步骤检查文件内容,这是因为 Android Lint 的项目是一个纯 Java 项目,不能使用 android.graphics 等包,因而开发时会比较繁琐。

Issue

在上面的例子中,在检查出问题需要进行报告时,context.report 方法中传入了一个 ISSUE_JAVA_VECTOR_DRAWABLE,这里的"issue"是声明一个规则,因此自定义一个 Lint 规则就需要定义一个 issue。issue 由类方法 Issue.create 创建,参数如下:

  • id:标记 issue 的唯一值,语义上要能简短描述问题,使用 Java 注解和 XML 属性屏蔽 Lint 时,就需要使用这个 id。
  • summary:概况地描述问题,不需要给出解决办法。
  • explanation:详细地描述问题以及给出解决办法。
  • category:问题类别,在系统给出的分类中选择,后面会详述。
  • priority:1-10 的数字,表示优先级,10 为最严重。
  • severity:严重级别,在 Fatal,Error,Warning,Informational,Ignore 中选择一个。
  • Implementation:Detector 与 Issue 的映射关系,需要传入当前的 Detector 类,以及扫描代码的范围,例如 Java 文件、Resource 文件或目录等范围。

如下图,产生问题时,问题的提醒信息就就会显示相关的 Issue 的 id 等信息。

Lint issue 示例

Category

Category 用于给 Issue 分类,系统已经提供了几个常用的分类,系统 Issue(即 Android Lint 自带的检查规则)也是使用这个 Category:

  • Lint
  • Correctness (子分类 Messages)
  • Security
  • Performance
  • Usability (子分类 Typography, Icons)
  • A11Y (Accessibility)
  • I18N (Internationalization,子分类 Rtl)

如果系统分类不能满足需求,也可以创建自定义的分类:

public class QMUICategory {
    public static final Category UI_SPECIFICATION = Category.create("UI Specification", 105);
}

使用如下:

public static final Issue ISSUE_JAVA_VECTOR_DRAWABLE =
        Issue.create("QMUIGetVectorDrawableWithWrongFunction",
                "Should use the corresponding method to get vector drawable.",
                "Using the normal method to get the vector drawable will cause a crash on Android versions below 4.0",
                QMUICategory.UI_SPECIFICATION, 2, Severity.ERROR,
                new Implementation(QMUIJavaVectorDrawableDetector.class, Scope.JAVA_FILE_SCOPE));

Registry

创建自定义 Lint 的最后一步是 “Lint-Registry”,如前面所述,build.gradle 中需要声明 Regisry 类,打包成 jar:

jar {
    manifest {
        attributes('Lint-Registry': 'com.qmuiteam.qmui.lint.QMUIIssueRegistry')
    }
}

而 registry 类中则是注册创建好的 Issue,以 QMUIIssueRegistry 为例:

public final class QMUIIssueRegistry extends IssueRegistry {
    @Override public List<Issue> getIssues() {
        return Arrays.asList(
                QMUIFWordDetector.ISSUE_F_WORD,
                QMUIJavaVectorDrawableDetector.ISSUE_JAVA_VECTOR_DRAWABLE,
                QMUIXmlVectorDrawableDetector.ISSUE_XML_VECTOR_DRAWABLE,
                QMUIImageSizeDetector.ISSUE_IMAGE_SIZE,
                QMUIImageScaleDetector.ISSUE_IMAGE_SCALE
        );
    }
}

QMUIIssueRegistry 继承与 IssueRegistryIssueRegistry 中注册了 Android Lint 自带的 Issue,而自定义的 Issue 则可以通过 getIssues 系列方法传入。

到这一步,这个用于自定义 Lint 的 Java 项目编写完毕了。

接入项目

按照上面的步骤,完成自定义 Lint 的编写后,编译 Gradle 可以得到对应的 jar 文件,那么 jar 应该如何接入项目,使得执行项目 Lint 时可以识别到这些自定义的规则呢?

Google 官方的方案是把 jar 文件放到 ~/.android/lint/,如果本地没有 lint 目录可以自行创建,这个使用方式较为简单,但也使得 Android Lint 作用于本地所有的项目,不大灵活。

因此我们推荐使用 Google adt-dev 论坛中被讨论推荐的方案,在主项目中新建一个 Module,打包为 aar,把 jar 文件放到该 aar 中,这样各个项目可以以 aar 的方式自行引入自定义 Lint,比较灵活,项目之间不会造成干扰。

Module 的 build.gradle 内容如下(以 QMUI Lint 为例):

apply plugin: 'com.android.library'

configurations {
    lintChecks
}

dependencies {
    lintChecks project(path: ':qmuilintrule', configuration: 'lintChecks')
}

task copyLintJar(type: Copy) {
    from(configurations.lintChecks) {
        rename { 'lint.jar' }
    }
    into 'build/intermediates/lint/'
}

project.afterEvaluate {
    def compileLintTask = project.tasks.find { it.name == 'compileLint' }
    compileLintTask.dependsOn(copyLintJar)
}

其中 qmuilintrule 是自定义 Lint 规则的 Module,这样这个需要进行 aar 打包的 Module 即可获取到 jar 文件,并放到 build/intermediates/lint/ 这个路径中。把 aar 发布到 Bintray 后,需要用到自定义 Lint 的地方只需要引入 aar 即可,例如:

compile 'com.qmuiteam:qmuilint:1.0.0'

另外需要注意,在编写自定义规则的 Lint 代码时,编写后重新构建 gradle,新代码也不一定生效,需要重启 Android Studio 才能确保新代码已经生效。

完整的示例代码可以参考 QMUI Androidqmuilintqmuilintrule module。

参考资料

]]>
https://kayosite.com/android-lint-custom-issue.html/feed 0 3562
Android Lint 实践 —— 简介及常见问题分析 https://kayosite.com/android-lint-intro-and-frequently-asked-questions.html https://kayosite.com/android-lint-intro-and-frequently-asked-questions.html#respond Wed, 11 Oct 2017 09:42:00 +0000 https://kayosite.com/?p=3554 概况

QMUI Android 刚更新了 1.0.4 版本,其中主要的特性是引入了 Android Lint,对项目代码进行优化。Android Lint 是 SDK Tools 16(ADT 16)开始引入的一个代码扫描工具,通过对代码进行静态分析,可以帮助开发者发现代码质量问题和提出一些改进建议。除了检查 Android 项目源码中潜在的错误,对于代码的正确性、安全性、性能、易用性、便利性和国际化方面也会作出检查。

而最终选择了 Android Lint 作为项目的代码检测工具,是因为它具有以下几个特性:

  • 已经被集成到 Android Studio,使用方便。
  • 能在编写代码时实时反馈出潜在的问题。
  • 可以自定义规则。Android Lint 本身包含大量已经封装好的接口,能提供丰富的代码信息,开发者可以基于这些信息进行自定义规则的编写。

开始使用

Android Lint 的工作过程比较简单,一个基础的 Lint 过程由 Lint Tool(检测工具),Source Files(项目源文件) 和 lint.xml(配置文件) 三个部分组成,Lint Tool 读取 Source Files,根据 lint.xml 配置的规则(issue)输出结果(如下图)。

Lint 工作流程
Lint 工作流程

如上面所描述,在 Android Studio 中,Android Lint 已经被集成,只需要点击菜单 —— Analyze —— Inspect Code 即可运行 Android Lint,在弹出的对话框中可以设置执行 Lint 的范围,可以选择整个项目,也可以只选择当前的子模块或者其他自定义的范围:

Specify Inspection Scope
选择执行 Lint 的范围

检查完毕后会弹出 Inspection 的控制台,并在其中列出详细的检查结果:

Lint 检查结果
Lint 执行结果

如上图所展示的,Android Lint 对检查的结果进行了分类,同一个规则(issue)下的问题会聚合,其中针对 Android 的规则类别会在分类前说明是 Android 相关的,主要是六类:

  • Accessibility 无障碍,例如 ImageView 缺少 contentDescription 描述,String 编码字符串等问题。
  • Correctness 正确性,例如 xml 中使用了不正确的属性值,Java 代码中直接使用了超过最低 SDK 要求的 API 等。
  • Internationalization 国际化,如字符缺少翻译等问题。
  • Performance 性能,例如在 onMeasureonDraw 中执行 new,内存泄露,产生了冗余的资源,xml 结构冗余等。
  • Security 安全性,例如没有使用 HTTPS 连接 Gradle,AndroidManifest 中的权限问题等。
  • Usability 易用性,例如缺少某些倍数的切图,重复图标等。

其他的结果条目则是针对 Java 语法的问题,另外每一个问题都有区分严重程度(severity),从高到底依次是:

  • Fatal
  • Error
  • Warning
  • Information
  • Ignore

其中 FatalError 都是指错误,但是 Fatal 类型的错误会直接中断 ADT 导出 APK,更为严重。另外如下图所示,在结果列表中点击一个条目,可以看到详细的源文件名和位置,以及命中的错误规则(issue)、解决方案或者屏蔽提示:

Lint 详情示例

上图的例子是在 ScrollView 的第一层子元素中设置了高度为 match_parent,Android Lint 会直接给出解决办法——使用 wrap_content 代替,大部分静态语法相关的问题 Android Lint 都可以直接给出解决办法。

除了直接在菜单中运行 Lint 外,大部分问题代码在编写时 Android Studio 就会给出提醒:

Lint 提醒示例

配置

对于执行 Lint 操作的相关配置,是定义在 gradle 文件的 lintOptions 中,可定义的选项及其默认值包括(翻译自 LintOptions - Android Plugin 2.3.0 DSL Reference):

android {
    lintOptions {
        // 设置为 true,则当 Lint 发现错误时停止 Gradle 构建
        abortOnError false
        // 设置为 true,则当有错误时会显示文件的全路径或绝对路径 (默认情况下为true)
        absolutePaths true
        // 仅检查指定的问题(根据 id 指定)
        check 'NewApi', 'InlinedApi'
        // 设置为 true 则检查所有的问题,包括默认不检查问题
        checkAllWarnings true
        // 设置为 true 后,release 构建都会以 Fatal 的设置来运行 Lint。
        // 如果构建时发现了致命(Fatal)的问题,会中止构建(具体由 abortOnError 控制)
        checkReleaseBuilds true
        // 不检查指定的问题(根据问题 id 指定)
        disable 'TypographyFractions','TypographyQuotes'
        // 检查指定的问题(根据 id 指定)
        enable 'RtlHardcoded','RtlCompat', 'RtlEnabled'
        // 在报告中是否返回对应的 Lint 说明
        explainIssues true
        // 写入报告的路径,默认为构建目录下的 lint-results.html
        htmlOutput file("lint-report.html")
        // 设置为 true 则会生成一个 HTML 格式的报告
        htmlReport true
        // 设置为 true 则只报告错误
        ignoreWarnings true
        // 重新指定 Lint 规则配置文件
        lintConfig file("default-lint.xml")
        // 设置为 true 则错误报告中不包括源代码的行号
        noLines true
        // 设置为 true 时 Lint 将不报告分析的进度
        quiet true
        // 覆盖 Lint 规则的严重程度,例如:
        severityOverrides ["MissingTranslation": LintOptions.SEVERITY_WARNING]
        // 设置为 true 则显示一个问题所在的所有地方,而不会截短列表
        showAll true
        // 配置写入输出结果的位置,格式可以是文件或 stdout
        textOutput 'stdout'
        // 设置为 true,则生成纯文本报告(默认为 false)
        textReport false
        // 设置为 true,则会把所有警告视为错误处理
        warningsAsErrors true
        // 写入检查报告的文件(不指定默认为 lint-results.xml)
        xmlOutput file("lint-report.xml")
        // 设置为 true 则会生成一个 XML 报告
        xmlReport false
        // 将指定问题(根据 id 指定)的严重级别(severity)设置为 Fatal
        fatal 'NewApi', 'InlineApi'
        // 将指定问题(根据 id 指定)的严重级别(severity)设置为 Error
        error 'Wakelock', 'TextViewEdits'
        // 将指定问题(根据 id 指定)的严重级别(severity)设置为 Warning
        warning 'ResourceAsColor'
        // 将指定问题(根据 id 指定)的严重级别(severity)设置为 ignore
        ignore 'TypographyQuotes'
    }
}

lint.xml 这个文件则是配置 Lint 需要禁用哪些规则(issue),以及自定义规则的严重程度(severity),lint.xml 文件是通过 issue 标签指定对一个规则的控制,在项目根目录中建立一个 lint.xml 文件后 Android Lint 会自动识别该文件,在执行检查时按照 lint.xml 的内容进行检查。如上面提到的那样,开发者也可以通过 lintOptions 中的 lintConfig 选项来指定配置文件。一个 lint.xml 示例如下:

<?xml version="1.0" encoding="UTF-8"?>
<lint>
    <!-- Disable the given check in this project -->
    <issue id="HardcodedText" severity="ignore"/>
    <issue id="SmallSp" severity="ignore"/>
    <issue id="IconMissingDensityFolder" severity="ignore"/>
    <issue id="RtlHardcoded" severity="ignore"/>
    <issue id="Deprecated" severity="warning">
        <ignore regexp="singleLine"/>
    </issue>
</lint>

issue 标签中使用 id 指定一个规则,severity="ignore" 则表明禁用这个规则。需要注意的是,某些规则可以通过 ignore 标签指定仅对某些属性禁用,例如上面的 Deprecated,表示检查是否有使用不推荐的属性和方法,而在 issue 标签中包裹一个 ignore 标签,在 ignore 标签的 regexp 属性中使用正则表达式指定了 singleLine,则表明对 singleLine 这个属性屏蔽检查。

另外开发者也可以使用 @SuppressLint(issue id) 标注针对某些代码忽略某些 Lint 检查,这个标注既可以加到成员变量之前,也可以加到方法声明和类声明之前,分别针对不同范围进行屏蔽。

常见问题

我们在使用 Android Lint 对项目进行检查后,整理了一些问题及解决方法,下面列举较为常见的场景:

ScrollView size validation

这也是上文提到过的一个情况,在 ScrollView 的第一层子元素中设置了高度为 match_parent,这是错误的写法,实际上在 measure 时这里必定会被当作 wrap_content 去处理,因此按照 Lint 的建议,直接改为 wrap_content 即可。

Handler reference leaks

Handler 引用的内存泄露问题,例如下面的例子:

protected static final int STOP = 0x10000;
protected static final int NEXT = 0x10001;

@BindView(R.id.rectProgressBar) QMUIProgressBar mRectProgressBar;
@BindView(R.id.circleProgressBar) QMUIProgressBar mCircleProgressBar;

int count;

private Handler myHandler = new Handler() {
    @Override
    public void handleMessage(Message msg) {
        super.handleMessage(msg);
        switch (msg.what) {
            case STOP:
                break;
            case NEXT:
                if (!Thread.currentThread().isInterrupted()) {
                    mRectProgressBar.setProgress(count);
                    mCircleProgressBar.setProgress(count);
                }
        }
    }
};

首先非静态的内部类或者匿名类会隐式的持有其外部类的引用,内部类使用了外部类的方法/成员变量也会导致其持有外部类引用,因此上面这种情况会导致 handler 持有了外部类,外部类同时持有 handler,handler 是异步的,当 handler 的消息发送出去后,外部类因 hanlder 的持有而无法销毁,最终导致内存泄露。

解决办法则是把该内部类改为 static,内部类中使用的外部类方法/成员变量改为弱引用,具体如下:

@BindView(R.id.rectProgressBar) QMUIProgressBar mRectProgressBar;
@BindView(R.id.circleProgressBar) QMUIProgressBar mCircleProgressBar;

int count;

private ProgressHandler myHandler = new ProgressHandler();

@Override
protected View onCreateView() {
    myHandler.setProgressBar(mRectProgressBar, mCircleProgressBar);
}

private static class ProgressHandler extends Handler {
    private WeakReference<QMUIProgressBar> weakRectProgressBar;
    private WeakReference<QMUIProgressBar> weakCircleProgressBar;

    public void setProgressBar(QMUIProgressBar rectProgressBar, QMUIProgressBar circleProgressBar) {
        weakRectProgressBar = new WeakReference<>(rectProgressBar);
        weakCircleProgressBar = new WeakReference<>(circleProgressBar);
    }

    @Override
    public void handleMessage(Message msg) {
        super.handleMessage(msg);
        switch (msg.what) {
            case STOP:
                break;
            case NEXT:
                if (!Thread.currentThread().isInterrupted()) {
                    if (weakRectProgressBar.get() != null && weakCircleProgressBar.get() != null) {
                        weakRectProgressBar.get().setProgress(msg.arg1);
                        weakCircleProgressBar.get().setProgress(msg.arg1);
                    }
                }
        }

    }
}

Memory allocations within drawing code

onMeasure、onDraw 都是被频繁调用的方法,因此 Lint 不建议在其中执行 new 操作,可以在 onCreateView 等非频繁调用的时机进行 new 操作,并用成员变量保存,再在 onMeasure 中使用成员变量。

‘private’ method declared ‘final’

private static final void addLinkMovementMethod(TextView t) {
    MovementMethod m = t.getMovementMethod();

    // ...
}

如上面的示例代码,会产生 ‘private’ method declared ‘final’ 的警告,因为私有方法是不会被 override 的,因此完全没有必要声明 final

参考资料:

使用 Lint 改进您的代码 | Android Studio
LintOptions - Android Plugin 2.3.0 DSL Reference
Android Lint Checks - Android Studio Project Site

]]>
https://kayosite.com/android-lint-intro-and-frequently-asked-questions.html/feed 0 3554
Web UI 解决方案 QMUI Web —— 探索与沉淀 https://kayosite.com/the-story-of-qmui-web.html https://kayosite.com/the-story-of-qmui-web.html#respond Thu, 07 Sep 2017 10:00:00 +0000 https://kayosite.com/?p=3673 经过长时间的打磨迭代,QMUI Web 作为腾讯广研 QMUI 团队的一个开源项目,正式发布到 Tencent Github。QMUI Web 是一个 Web UI 的解决方案,从零开始,由编码规范,到组件和工具方法的制作,再到工作流的整合,不断在迭代,也不断在优化,走过了不少的路。趁着发布的机会,我们正好回顾这一路的探索过程,分享其中的点滴,也希望能借此让大家更了解 QMUI Web。

背景

2014 年中,QMUI 团队支持的主要项目是 QQ 邮箱,Web 端的邮箱是个庞大的项目,但其并没有统一的 UI 基础库,多年的高速迭代使得项目的 UI 代码变得混乱,各个模块之间各自开发,除了在代码层面表现出混乱和不可控之外,表现层面也并没有很好地统一起来。因此,项目急需一套统一的团队编码规范以及一个 UI 基础库。
恰好,这个时候 Sass 等 CSS 预处理器已经发展成熟,自动化工作流的工作模式也日趋完善,因此,我们决定基于这些技术制作一套通用于不同项目的 Web UI 框架。框架的场景定位很明确:需要控制整体样式,并且可以适应频繁迭代打磨的大型项目。所以,这套即将诞生的 Web UI 框架的特性也很明确:需要方便地控制项目的整体样式,应对频繁的界面变动,并保持项目质量稳健。
此后经过三年的发展,QMUI Web 最终发展为包含编码规范、样式工具方法与样式管理、内置工作流,配套的 GUI 桌面 App,以及拥有完整文档的解决方案。

设计理念

在制作框架的过程中,我们把框架需要的特性进行整理和思考,形成了一套对于该框架的设计理念,在这些设计理念之中,最核心的关键为通用于多个项目高效迭代保持代码稳健,框架的设计也遵循这三个核心点,体现在框架上,具体就是:

  • 框架和组件需要剥离业务。作为 UI 框架,框架内整合的组件和样式必须有能力剥离业务,才能跨项目使用。
  • 能轻易控制整体样式。需要高效地迭代项目,样式的整体控制必不可少。
  • 保持代码稳健。

而具体到代码层面,则可以归纳为两个方面:

  • Class-name 命名规范。
  • 基础样式配置与半封装组件。

Class-name 命名规范

作为一个 Web UI 框架,编写代码主要是 CSS 与 HTML,而提到 CSS 与 HTML 的编写,首先要处理的是 Class-name 的命名,在过往的开发中,Class-name 的命名并没有固定的规范,开发人员各自进行开发,一个项目经过长时间的迭代后,经常会遇到如命名冲突,命名混乱等问题,这使得项目的迭代变得笨重,也不好维护。因此,我们需要一套具有如下特点的 Class-name 命名规范:

  • 命名有迹可循,容易编写。
  • 避免命名冲突,包括内部多人协作命名冲突,以及外部库引入时的被动污染。
  • 命名具有语义,能晰地描述整个页面,方便理解上下文。

因此,最终 QMUI Web 制定了一套以命名空间为核心的命名方式,这个命名方式主要由“命名空间”,“业务与组件的拆分”,“精确表达 View”三个部分构成。

命名空间

一个 QMUI Web Class-name 应该包含一个命名空间,也是 Class-name 的开头,如果是业务,则以业务内容为命名空间,如果是公共组件,则全局使用项目的名字(或缩写)为命名空间。如一个名为 Demo 的项目,项目缩写可以是 dm,那么该项目下的项目组件和公共类可以这样命名: dm_btn(按钮)、dm_icon(图标)、dm_ipt(输入框)、dm_toolbar(工具栏)。
逻辑模块命名以具体业务作为前缀,如简历(resume)功能里面的非公共组件部分,以 resume_ 作为前缀(resume_modresume_textresume_list),个人信息(profile)页面的非公共组件部分,则可以以 profile_ 作为前缀(profile_statgeprofile_stage_title)。
命名空间作为一种基础的隔离,把组件与业务,以及不同的业务之间的 Class-name 命名隔离开来,避免冲突,而后父子元素之间逐级展开编写,保证了项目内多人协助不易冲突,同时命名带有语义,也方便理解和阅读。

父子元素命名示例
扩展元素的命名示例

业务与组件的拆分

接着,QMUI Web 中把项目的代码划分为通用组件(跨项目的组件),项目全局组件(适用于某个具体项目),业务组件(适用于某个业务),以及业务逻辑代码,这样区分出4个颗粒度可以使得代码更容易被组织和复用,一个模块随着设计元素迭代,也可以在这4个颗粒度之间进行迭代,从而使得模块在迭代时会更加稳健。而 QMUI Web 框架中的组件应该只收纳通用组件,即跨项目组件。

精准表达 View

精准表达 View 是指在命名 DOM 节点时要明确这是一个怎样的 View,这里的 View 指的就是 UI 层面上这个元素表示的含义,常见的场景是,一个命名为 resume_head 的元素,在经历多次迭代后实际在代码中却充当了页脚,这样的命名在多人协作时很容易给后面的开发者造成困扰,而精准表达 View 则要求我们明确一个 UI 元素的含义,并在命名时准确地表达。

基础样式配置与半封装组件

前面的“Class-name 命名规范”主要是在规范层面上去实践 QMUI Web 的核心理念,而接着更多地就是在代码层面上去实践了,主要包括三点:

  • 半封装组件,即面向项目的组件。
  • 使用组合而不是继承。
  • 颗粒度的把控。

半封装组件即面向项目的组件

前文提到,QMUI Web 把组件划分为通用组件,项目全局组件,业务组件三种组件,而 QMUI Web 框架收纳的则是通用组件,也是跨项目的组件,但每个项目的 UI 表现并无关联,如何处理跨项目组件就成为了一个问题。为此,QMUI 在处理组件时采取的是“半封装”的处理方式,QMUI 框架封装的是代码,所谓半封装,即封装那些与项目具体 UI 表现没有必然联系的代码。例如按钮组件,QMUI Web 中只封装了文字居中对齐,鼠标手型,浏览器样式重置,低版本 IE 兼容性处理等代码,而常用的样式如边框、背景、字体表现等,都抽取成变量控制,这些组件的变量最终都汇集到一个配置表 Sass 文件中,配合全局的颜色变量、字体变量等变量,就可以做到跨项目抽取组件,每个新项目只需要关注具体 UI 表现而无需再处理各种常见的 UI 问题,同时方便地通过调整这些变量的值而快速修改整个项目的样式。

按钮组件样式代码

组合而不是继承

在处理组件时,继承的方式是指一个组件类承担复杂的功能,而组合的方式则是把组件类拆分成一个基类,以及多个子类,每个子类承担的功能不重复,对于我们的主场景——频繁迭代,保持稳健,显然组合会更加适合,这种方式避免了在频繁的迭代中需要不断修改组件类,每次迭代只需要修改对应的子类即可。

颗粒度

对于组件的抽取,时常要考虑颗粒度的划分,颗粒度本身就是一个比较开放性的问题,在这里与大家分享一些沉淀的经验:

  • 抽取组件以 UI 表现为区分,例如一个删除按钮,是以删除 icon + 删除文案作为内容的,但在表现上它就是一个带 icon 的文字按钮,因此就抽取出一个支持 icon 的文字按钮,而不用只局限于按“删除”这个业务来命名组件。
  • 抽取组件可以选择较大的颗粒度,也可以选择较小的颗粒度。颗粒度较大的组件实现复杂,能对应复杂的场景,但扩展性也会因此下降,而颗粒度较小的组件则实现简单,能轻松实现一个主场景,但又方便扩展,能灵活地应对变化。因此建议是像按钮、输入框、下拉菜单这类通常位于页面 DOM Tree 末端的元素可以抽取成尽量简单的组件,同时通过扩展的方式去处理各种场景差异。而其他复杂的组件则可以专注于一个业务,不必过多地考虑不同的场景,否则组件很容易变得难以维护。

以上便是 QMUI Web 具体的设计理念,通过命名规范、基础样式配置与半封装组件来保证多人协作时的高效率与可维护性,也使得一个 UI 框架能为不同的项目服务。

具体组成

作为一个框架,QMUI Web 主要提供了四种能力来提升 UI 开发的效率与质量,对应前文提到的框架设计理念,QMUI Web 提供的这些功能都是为了帮助开发者方便地控制项目整体样式,应对频繁变动,同时保持代码稳健。

基础配置与组件

前文提到,框架中会有一份配置表,是各种 Sass 的变量,这些变量控制了一个网页基本的字体样式,链接颜色,通用组件的样式配置等基础样式,在创建一个新项目时,应该先根据设计稿配置好这些信息,当这些信息配置完成,那么一个项目的基本样式就可以快速实现了。例如下图中这些配置属于 QMUI 通用配置,通过修改这些配置则可以快速修改项目的字体策略、正文字体大小,链接颜色等 UI 常用的 CSS 属性。

基础变量代码

内置工作流

QMUI 中包含一个基于 gulp 的内置工作流,用于快速解决大量重复劳动力的工作,从而提升效率。QMUI 的 gulp 中预先实现了监控 Sass 文件并自动编译和优化,雪碧图处理,模板 include 能力(可以传参和使用条件判断),浏览器自动刷新,图片压缩,文件清理,文件合并以及自动变更等能力。

工作流运行日志

Sass 增强支持

QMUI 中提供了大量基于 Sass 的 CSS 预处理的方法,包括 CSS Reset,一些常见的 CSS 类(例如清除浮动),计算长度值的简便方法(例如获取 padding 在某个方向的值,计算两个长度值的中间值),快速实现一些样式效果的工具方法(例如实现 border 三角形,适应多倍屏幕的 1px 边框等),这些都是用于提高样式开发的效率和质量。

扩展组件

扩展组件并不是由 QMUI Web 的主源码提供,而是由 Demo 提供,通常是因为这类组件结构较复杂,因此业务性无法很好地剥离,从而不能抽取成公共组件,因此这类组件就放在一个 Demo 页,以参考组件的形式帮助开发。

GUI

我们提供了一个用于管理 QMUI Web 项目的桌面 App,在代码层面它独立于 QMUI Web 的源码。它通过 GUI 界面处理 QMUI Web 的服务开启/关闭,并提供了编译提醒,出错提醒,进程关闭提醒等额外的功能,在处理多项目,多分支时能更方便地进行开发。

GUI 运行效果

优化和开源

在经历较长时间的迭代后,QMUI Web 也逐渐完善起来,此时我们也开始将 QMUI Web 进行开源。开源意味着 QMUI Web 会进入更加全面的环境中去打磨,在框架的非主体内容如代码规范、注释、文档上面也需要更费心思,考虑的点也需要更加周全。这对团队来说无疑是个很好的机会,可以有更多的渠道审视框架,吸收建议,持续进行优化。

在加入开源的大环境后,我们从 Github、社区论坛中都获取了不少建议,除了 bug 的反馈外,也指出了一些待完善的地方和提出一些优化的解决方案,从而使得 QMUI Web 注入了更多活力,因此我们也逐步进行了如“自动化测试用例”、“gulp 结构化”,“引入 SassDoc 自动化生成文档”,“编译 Sass 时引入增加更新”等优化,其中不少优化点我们也在项目的 Github Wiki 中进行了详细的分享,有兴趣的用户可以自行浏览。

总结与展望

至此,QMUI Web 发展为现在这套完整的方案,也终于开源到 Github Tencent 与大家分享,我们期望通过开源与大家进行更多的交流,也使得 QMUI Web 进入更加全面的环境中去打磨,形成对代码规范、注释、项目文档感谢公司与部门给我们提供了一个平台,可以在大型项目中经历迭代和沉淀。开源只是一个开始,我们后续仍会不断进行探索和优化,期待更好的 QMUI Web。

仓库地址:

https://github.com/Tencent/QMUI_Web

]]>
https://kayosite.com/the-story-of-qmui-web.html/feed 0 3673
Gulp 结构化最佳实践 https://kayosite.com/gulp-module-best-practice.html https://kayosite.com/gulp-module-best-practice.html#respond Tue, 23 Aug 2016 13:51:00 +0000 https://kayosite.com/?p=3551 在 Gulp 的官方文档中,Gulp 的任务都是写在 gulpfile.js 这一个文件中的,如果任务数量不多,这并不会有什么问题,但当任务数量较多时,会造成代码可读性差,难以维护,多人协作时还会容易造成冲突。因此,更好的处理方式是把 Gulp 的代码结构化。

开始结构化

https://github.com/QMUI/qmui_web

这是一个前端框架,主要由一个 SASS 方法合集与内置的工作流构成,其中工作流部分提供了一系列的任务用于处理前端流程,并且由于是可配置的框架,需要读取配置文件,因此虽然原有的 gulpfile.js 的代码并不庞大,但仍然需要进行结构化处理,本文将会详细说明如何进行结构化处理。

主要的思路是把 gulpfile.js 中的任务分散到独立的文件中编写,然后在 gulpfile.js 中引入这些 task。因此最简便的方法是把每个 task 单独写在独立的文件中,以 task 名命名文件名,在 gulpfile.js 中把这些文件读取进去,例如:

workflow/task/clean.js

var del = require('del');

gulp.task('clean', '清理多余文件(清理内容在 config.json 中配置)', function() {
    // force: true 即允许 del 控制本目录以外的文件
    del(common.config.cleanFileType, {force: true});
    console.log(common.plugins.util.colors.green('QMUI Clean: ') + '清理所有的 ' + common.config.cleanFileType + ' 文件');
});

gulpfile.js

var gulp        = require('gulp'),
    requireDir  = require('require-dir');

// 遍历目录,加载 task 代码
requireDir('./workflow/task', { recurse: true });

gulp.task('default', ['clean']);

这种方法操作起来比较简单,同时基本不需要改动原有的代码,只需对 gulpfile.js 稍作改动即可。但同时也引入了一些问题,例如,文章开头说过的,像 QMUI 这类需要读取公共配置文件的需求,这里就无法解决,各个任务中如果需要引入配置表,都需要单独引入,同时像工具方法这类内容也会重复引入,造成浪费。因此实际上,clean.js 中也不是像上面的例子那样编写的,而是采用 module 的方式拆分任务。

Module 形式的结构化

为了避免在子任务文件中重复引入全局的配置、插件依赖和工具方法,更好的方式就是把全局配置、工具方法以及子任务都拆分成模块,并利用 require 的方式引入模块。

首先,可以先看一下结构化后的目录结构:

.
├── gulpfile.js
├── package.json
└── workflow
    ├── common.js
    ├── lib.js
    └── task
        ├── clean.js
        ├── compass.js
        ├── include.js
        ├── initProject.js
        ├── merge.js
        ├── readToolMethod.js
        ├── start.js
        ├── version.js
        └── watch.js

接下来以其中几个文件为示例:

common.js

// 声明插件以及配置文件的依赖
var plugins     = require('gulp-load-plugins')({
                    rename: {
                      'gulp-file-include': 'include',
                      'gulp-merge-link': 'merge'
                    }
                  }),
    packageInfo = require('../package.json'),
    lib         = require('./lib.js'),
    config      = require('./config.js');;

// 创建 common 对象
var common = {};

common.plugins = plugins;
common.config = config;
common.packageInfo = packageInfo;
common.lib = lib;

module.exports = common;

clean.js

var del = require('del');

module.exports = function(gulp, common) {
  gulp.task('clean', '清理多余文件(清理内容在 config.json 中配置)', function() {
    // force: true 即允许 del 控制本目录以外的文件
    del(common.config.cleanFileType, {force: true});
    common.plugins.util.log(common.plugins.util.colors.green('QMUI Clean: ') + '清理所有的 ' + common.config.cleanFileType + ' 文件');
  });
};

gulpfile.js

/**
 * gulpfile.js QMUI Web Gulp 工作流
 */
 var gulp = require('gulp-help')(require('gulp'), {
             description: '展示这个帮助菜单',
             hideDepsMessage: true
           }),
    fs = require('fs'),
    common = require('./workflow/common.js');

// 载入任务
var taskPath = 'workflow/task';

fs.readdirSync(taskPath).filter(function (file) {
  return file.match(/js$/); // 排除非 JS 文件,如 Vim 临时文件
}).forEach(function (_file) {
  require('./' + taskPath + '/' + _file)(gulp, common);
});

总结如下:

  • 公共的配置、插件依赖和工具方法使用一个 common 对象关联起来,并且封装成模块
  • 每个子任务封装成模块,并且可以传入 gulp 和 common 两个参数,这样公共的部分可以复用
  • gulpfile.js 中遍历任务目录,对所有子任务都执行 require,所有子任务都在 gulpfile.js 中成功注册

至此,一个完整的结构化 Gulp 就处理好了,Gulp 的目录结构变得清晰很多,这时候无论是增加工具方法,增删子任务,尤其是多人协作时都会方便很多了。

注意事项

除了以上的主要思路,在实践中一些事项需要注意:

  • 子任务是被 gulpfile.js require 进去的,因此 gulp.srcgulp.dest 的相对目录关系并不需要修改,依然是以 gulpfile.js 所在目录为基准。但子任务文件中 require 文件是以子任务文件所在目录为基准的,如上面的代码中 common.js 在引入 package.json 是需要在上层目录中进行操作 —— packageInfo = require('../package.json')
  • require 当前目录的模块不能省略 ./,否则无效。
  • 需要有项目中依赖了多个 gulp 的插件,推荐使用 gulp-load-plugins 插件管理多个插件。

]]>
https://kayosite.com/gulp-module-best-practice.html/feed 0 3551
SassDoc 详细介绍与最佳实践 https://kayosite.com/sassdoc-introduction-and-best-practices.html https://kayosite.com/sassdoc-introduction-and-best-practices.html#respond Mon, 22 Aug 2016 08:20:00 +0000 https://kayosite.com/?p=3543 SassDoc 是一款专门为 Sass 代码生成注释的工具,通过 SassDoc,开发者可以通过类似 JSDoc 的方式在 Sass 代码上添加注释,然后直接用命令生成文档。最近在处理团队框架 QMUI Web 时,遇到了需要为大量 Sass 方法写文档的问题,因此研究了这个工具,本文将会详细说明 SassDoc 的使用方法以及其中的最佳实践。

基本使用

在 Sass 中,可以使用多行注释 /* xxxx */ 和单行注释 // xxxx 两种注释方法。如文章开头所述,SassDoc 是使用类似 JSDoc 的方式,即在代码中通过注释编写文档内容的方式生成文档,因此 SassDoc 有特定的注释语法:

/// 跨浏览器的渐变背景,垂直渐变,自上而下
///
/// @group 外观
/// @name gradient_vertical
/// @param {Color} $start-color [#555] - 渐变的开始颜色
/// @param {Color} $end-color [#333] - 渐变的结束颜色
/// @param {Number} $start-percent [0%] - 渐变的开始位置,需要以百分号为单位
/// @param {Number} $end-percent [100%] - 渐变的结束位置,需要以百分号为单位
@mixin gradient_vertical($start-color: #555, $end-color: #333, $start-percent: 0%, $end-percent: 100%){
  background-image: -webkit-gradient(linear, left top, left bottom, color-stop($start-percent, $start-color), color-stop($end-percent, $end-color)); // Safari 4-5, Chrome 1-9
  background-image: -webkit-linear-gradient(top, $start-color $start-percent, $end-color $end-percent);  // Safari 5.1-6, Chrome 10+
  background-image: -moz-linear-gradient(top, $start-color $start-percent, $end-color $end-percent); // Firefox 3.6+
  background-image: -o-linear-gradient(top, $start-color $start-percent, $end-color $end-percent);  // Opera 12
  background-image: linear-gradient(to bottom, $start-color $start-percent, $end-color $end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+
  background-repeat: repeat-x;
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#{ie-hex-str($start-color)}', endColorstr='#{ie-hex-str($end-color)}', GradientType=0); // IE9 and down
}

总结如下:

  • 使用 /// 作为 SassDoc 的注释标识(旧版的 SassDoc 中,使用的是 Sass 的注释方式,但这样这些注释也会被输出到 CSS 代码中,因此最新版的 SassDoc 选择重新定义一个 /// 作为专属的注释方式)
  • /// 中的第一行没有任何标记的文字会被当作 Sass 方法的描述
  • 带有 @name@param 这类标记的会当作对应的注释属性,完整的标记列表可以参考 http://sassdoc.com/annotations/

按照以上的方法,在 Sass 代码上写好了需要的注释,接下来就应该输出文档了。输出文档首先要安装 SassDoc 工具:

npm install sassdoc -g

然后对需要生成文档的 Sass 文件执行如下命令:

sassdoc sassFileName

例如:对 _compatible.scss 执行上面的操作,会直接生成如下的文档页面:

Sassdoc 示例
SassDoc 默认效果示例

如上图各个方法已经根据注释的内容输出对应的文档,并且文档的样式也很完善。至此,就是 SassDoc 的基本使用。

进阶使用

使用非默认主题以及其他选项

如果对默认的样式不满意,也可以使用官网提供的其他主题,在介绍如何使用其他主题时,先要介绍一下 SassDoc 的选项:

  • dest SassDoc 的输出目录,默认为 ./sassdoc
  • exclude 排除某些 Sass 文件,可以使用 * 通配符,类型为数组
  • package 类型为 String 或 Object,该选项可以告知 SassDoc 项目的标题,版本号等信息,默认值为 ./package.json
  • theme 文档的主题,默认为 default
  • autofill 规定那些属性需要尽量自动补全,默认为 ["requires", "throws", "content"]
  • groups 该方法的分组,文档中会根据把同一个分组的方法归类到一起展示,默认为 { undefined: "general" }
  • no-update-notifier 在使用 SassDoc 时(例如执行输出文档的命令),如果当前的 SassDoc 不是最新版本,会有输出提示,这个选项可以控制取消这个提示,默认为 false
  • verbose SassDoc 默认不会输出各个文档的生成进度,如果需要可以把这个选项设置为 true
  • strict 严格模式,默认为 false,开启后则使用一些废弃语法会报错(例如上面提到的旧版中可以使用的多行注释)

可以看出,如果希望使用其他主题,只需要下载对应的主题,并且在 theme 这个选项中进行配置即可,官方的其他主题列表

文件级注解

SassDoc 提供了一个文件级注解的功能,文件级注解与上面的普通注解相似,但是并不是书写在每个方法之上,而是写在文件的开头,它作用是当方法的注解缺少某些属性时,会自动把文件级注解当作缺省值使用。

例如在 _calculate.scss 中,方法的注解中都没有写 group 这个属性,但在文件级注解中有 group 属性,后续生成的文档都会以文件级注解中的 group 值当作自身的值。

代码:

////
/// 辅助数值计算的工具方法
/// @author Kayo 
/// @group 数值计算 
/// @date 2015-08-23
////

/// 获取 CSS 长度值属性(例如:margin,padding,border-width 等)在某个方向的值
///
/// @name getLengthDirectionValue
/// @param {String} $property - 记录着长度值的 SASS 变量 
/// @param {String} $direction - 需要获取的方向,可选值为 top,right,bottom,left,horizontal,vertical,其中 horizontal 和 vertical 分别需要长度值的左右或上下方向值相等,否则会报 Warning。
/// @example
///   // UI 界面的一致性往往要求相似外观的组件保持距离、颜色等元素统一,例如:
///   // 搜索框和普通输入框分开两种结构处理,但希望搜索框的搜索 icon 距离左边的空白与
///   // 普通输入框光标距离左边的空白保持一致,就需要获取普通输入框的 padding-left
///   $textField_padding: 4px 5px;
///   .dm_textField {
///     padding: $textField_padding;
///   }
///   .dm_searchInput {
///     position: relative;
///     ...
///   }
///   .dm_searchInput_icon {
///     position: absolute;
///     left: getLengthDirectionValue($textField_padding, left);
///     ...
///   }
@function getLengthDirectionValue($property, $direction) {
  ...
}

效果:

数值计算方法的效果
文件级注释示例

最佳实践

完全自定义外观

如果你不喜欢 SassDoc 提供的主题,或者本身的文档有一整套样式(例如上面提到的框架有自己的完整官网,因此 Sass 方法的文档也需要配合官网的风格),那么你就需要完全自定义样式。对开发者来说,常用的思路应该是把 Sass 代码中的注释输出为特定格式,例如 JSON,然后页面中通过读取这些数据输出 HTML。

SassDoc 中也提供了一些相关的接口,第一步是把指定的 Sass 文件读取出里面的注解,并输出数组,在此之前,你需要建立一个脚手架,方便你调用这个任务,持续地更新你的文档,例如使用 Gulp,首先在本地目录安装 SassDoc:

npm install sassdoc --save-dev

然后建立一个 Gulp 的 Task:

gulp.task('readToolMethod', false, function(){
  var sassdoc = require('sassdoc');

  sassdoc.parse([
    './qmui/helper/mixin/'
  ], {verbose: true})
  .then(function (_data) {
    // 文档的数据
    console.log(_data);
  });
});

可以看到,会输出数组格式的数据,并把每个方法作为一个 Object,Object 中包含了各个注解属性及其属性值:

SassDoc 解析出的数据格式
SassDoc 解析出的数据格式

接下来,你就可以根据这个数据拼接 HTML 了。

数据分组

SassDoc 中输出的数组数据并没有按不同 Group 把方法归类到不同的数组中,但在拼接 HTML 时,我们一般需要把方法按 group 归类到不同的数组中,方便遍历。这里给出一个方法,把刚刚的数组按 group 拆分成二维数组:

gulp.task('readToolMethod', false, function(){
    var fs = require('fs'),
      sassdoc = require('sassdoc'),
      _ = require('lodash');

  sassdoc.parse([
    './qmui/helper/mixin/'
  ], {verbose: true})
  .then(function (_data) {
    if (_data.length > 0) {
      // 按 group 把数组重新整理成二维数组
      var _result = [],
          _currentGroup = null,
          _currentGroupArray = null;
      for (var _i = 0; _i < _data.length; _i++) {
        var _item = _data[_i];
        // 由于 IE8- 下 default 为属性的保留关键字,会引起错误,因此这里要把参数中这个 default 的 key 从数据里改名
        if (_item.parameter) {
          for (var _j = 0; _j < _item.parameter.length; _j++) {
            var _paraItem = _item.parameter[_j];
            if (_paraItem.hasOwnProperty('default')) {
              _paraItem['defaultValue'] = _paraItem['default'];
              delete _paraItem['default'];
            }
          }
        }

        if (!_.isEqual(_item.group, _currentGroup)) {
          _currentGroup = _item.group;
          _currentGroupArray = []; 
          _result.push(_currentGroupArray); 
        } else {
          _currentGroupArray = _result[_result.length - 1];
        }
        _currentGroupArray.push(_item);
      }
      _result.reverse();

      // 准备把数组写入到指定文件中
      var _outputPath = './qmui_tools.json';

      // 写入文件
      fs.writeFileSync(_outputPath, 'var comments = ' + JSON.stringify(_result), 'utf8');
    }
  });
});

上面演示了如何把数组按 group 拆分为二维数组,并以 JSON 格式写入到文件中,方便拼接 HTML。需要注意的是,@param 这个注解中有一个 default 属性,代表参数的默认值,因此生成 JSON 后会产生一个 default 属性,在 IE8- 下,default 为属性的保留关键字,直接使用会引起错误,因此这里要把参数中这个 default 的 key 从数据里重命名,避免发生这种错误。

重新拼接方法体

在书写文档时,一般需要列出方法体(即完整的方法声明),例如:

onePixelBorder 方法
方法主题展示示例

SassDoc 输出的数据中本身不包含方法体,但是提供了组成方法体需要的数据,下面的方法可以利用一个 SassDoc 的 item 拼接处完整的方法体:

var makeCompleteMethodWithItem = function(item) {
  var result = '',
      itemType = null;

  if (item.context.type === 'placeholder') {
    itemType = '%';
  } else {
    itemType = item.context.type + ' ';
    result = '@';
  }

  result = result + itemType + item.context.name;
  if (item.parameter) {
    result += '(';

    var paraList = item.parameter;
    for (var paraIndex = 0; paraIndex < paraList.length; paraIndex++) {
      var paraItem = paraList[paraIndex];
      if (paraIndex !== 0) {
        result += ', $';
      } else {
        result += '$';
      }
      result += paraItem.name;
      if (paraItem.defaultValue) {
        result = result + ': ' + paraItem.defaultValue;
      }
    }
    result += ')';
  }
  result += ' { ... }';

  return result;
};

SassSDocMeister

最后推荐一款官方的工具—— SassSDocMeister,这个工具可以在线预览 SassDoc 注解的效果,对于刚接触 SassDoc 的用户来说会比较方便。

完整的 Demo 请参考 QMUI WebQMUI Web Demo,如果你觉得这篇文章对你有帮助,欢迎 Star。

]]>
https://kayosite.com/sassdoc-introduction-and-best-practices.html/feed 0 3543
iOS 开发之照片框架详解之二 —— PhotoKit 详解(下) https://kayosite.com/ios-development-and-detail-of-photo-framework-part-three.html https://kayosite.com/ios-development-and-detail-of-photo-framework-part-three.html#comments Mon, 21 Sep 2015 13:08:24 +0000 http://kayosite.com/?p=3486 这里接着前文《iOS 开发之照片框架详解之二 —— PhotoKit 详解(上)》,主要是干货环节,列举了如何基于 PhotoKit 与 AlAssetLibrary 封装出通用的方法。

三. 常用方法的封装

虽然 PhotoKit 的功能强大很多,但基于兼容 iOS 8.0 以下版本的考虑,暂时可能仍无法抛弃 ALAssetLibrary,这时候一个比较好的方案是基于 ALAssetLibrary 和 PhotoKit 封装出一系列模拟系统 Asset 类的自定义类,然后在其中封装好兼容 ALAssetLibrary 和 PhotoKit 的方法。

这里列举了四种常用的封装好的方法:原图,缩略图,预览图,方向,下面直接上代码,代码中有相关注释解释其中的要点。其中下面的代码中常常出现的 [[QMUIAssetsManager sharedInstance] phCachingImageManager] 是 QMUI 框架中封装的类以及单例方法,表示产生一个 PHCachingImageManager 的单例,这样做的好处是 PHCachingImageManager 需要占用较多的资源,因此使用单例可以避免无谓的资源消耗,另外请求图像等方法需要基于用一个 PHCachingImageManager 实例才能进行进度续传,管理请求等操作。

1. 原图

由于原图的尺寸通常会比较大,因此建议使用异步拉取,但这里仍同时列举同步拉取的方法。这里需要留意如前文中所述,ALAssetRepresentation 中获取原图的接口 fullResolutionImage 所得到的图像并没有带上系统相册“编辑”(选中,滤镜等)的效果,需要额外获取这些效果并手工叠加到图像上。

.h 文件

/// Asset 的原图(包含系统相册“编辑”功能处理后的效果)
- (UIImage *)originImage;

/**
 *  异步请求 Asset 的原图,包含了系统照片“编辑”功能处理后的效果(剪裁,旋转和滤镜等),可能会有网络请求
 *
 *  @param completion        完成请求后调用的 block,参数中包含了请求的原图以及图片信息,在 iOS 8.0 或以上版本中,
 *                           这个 block 会被多次调用,其中第一次调用获取到的尺寸很小的低清图,然后不断调用,直接获取到高清图,
 *                           获取到高清图后 QMUIAsset 会缓存起这张高清图,这时 block 中的第二个参数(图片信息)返回的为 nil。
 *  @param phProgressHandler 处理请求进度的 handler,不在主线程上执行,在 block 中修改 UI 时注意需要手工放到主线程处理。
 *
 *  @wraning iOS 8.0 以下中并没有异步请求预览图的接口,因此实际上为同步请求,这时 block 中的第二个参数(图片信息)返回的为 nil。
 *
 *  @return 返回请求图片的请求 id
 */
- (NSInteger)requestOriginImageWithCompletion:(void (^)(UIImage *, NSDictionary *))completion withProgressHandler:(PHAssetImageProgressHandler)phProgressHandler;

 .m 文件

- (UIImage *)originImage {
    if (_originImage) {
        return _originImage;
    }
    __block UIImage *resultImage;
    if (_usePhotoKit) {
        PHImageRequestOptions *phImageRequestOptions = [[PHImageRequestOptions alloc] init];
        phImageRequestOptions.synchronous = YES;
        [[[QMUIAssetsManager sharedInstance] phCachingImageManager] requestImageForAsset:_phAsset
                                                                              targetSize:PHImageManagerMaximumSize
                                                                             contentMode:PHImageContentModeDefault
                                                                                 options:phImageRequestOptions
                                                                           resultHandler:^(UIImage *result, NSDictionary *info) {
                                                                               resultImage = result;
                                                                           }];
    } else {
        CGImageRef fullResolutionImageRef = [_alAssetRepresentation fullResolutionImage];
        // 通过 fullResolutionImage 获取到的的高清图实际上并不带上在照片应用中使用“编辑”处理的效果,需要额外在 AlAssetRepresentation 中获取这些信息
        NSString *adjustment = [[_alAssetRepresentation metadata] objectForKey:@"AdjustmentXMP"];
        if (adjustment) {
            // 如果有在照片应用中使用“编辑”效果,则需要获取这些编辑后的滤镜,手工叠加到原图中
            NSData *xmpData = [adjustment dataUsingEncoding:NSUTF8StringEncoding];
            CIImage *tempImage = [CIImage imageWithCGImage:fullResolutionImageRef];
            
            NSError *error;
            NSArray *filterArray = [CIFilter filterArrayFromSerializedXMP:xmpData
                                                         inputImageExtent:tempImage.extent
                                                                    error:&error];
            CIContext *context = [CIContext contextWithOptions:nil];
            if (filterArray && !error) {
                for (CIFilter *filter in filterArray) {
                    [filter setValue:tempImage forKey:kCIInputImageKey];
                    tempImage = [filter outputImage];
                }
                fullResolutionImageRef = [context createCGImage:tempImage fromRect:[tempImage extent]];
            }   
        }
        // 生成最终返回的 UIImage,同时把图片的 orientation 也补充上去
        resultImage = [UIImage imageWithCGImage:fullResolutionImageRef scale:[_alAssetRepresentation scale] orientation:(UIImageOrientation)[_alAssetRepresentation orientation]];
    }
    _originImage = resultImage;
    return resultImage;
}

- (NSInteger)requestOriginImageWithCompletion:(void (^)(UIImage *, NSDictionary *))completion withProgressHandler:(PHAssetImageProgressHandler)phProgressHandler {
    if (_usePhotoKit) {
        if (_originImage) {
            // 如果已经有缓存的图片则直接拿缓存的图片
            if (completion) {
                completion(_originImage, nil);
            }
            return 0;
        } else {
            PHImageRequestOptions *imageRequestOptions = [[PHImageRequestOptions alloc] init];
            imageRequestOptions.networkAccessAllowed = YES; // 允许访问网络
            imageRequestOptions.progressHandler = phProgressHandler;
            return [[[QMUIAssetsManager sharedInstance] phCachingImageManager] requestImageForAsset:_phAsset targetSize:PHImageManagerMaximumSize contentMode:PHImageContentModeDefault options:imageRequestOptions resultHandler:^(UIImage *result, NSDictionary *info) {
                // 排除取消,错误,低清图三种情况,即已经获取到了高清图时,把这张高清图缓存到 _originImage 中
                BOOL downloadFinined = ![[info objectForKey:PHImageCancelledKey] boolValue] && ![info objectForKey:PHImageErrorKey] && ![[info objectForKey:PHImageResultIsDegradedKey] boolValue];
                if (downloadFinined) {
                    _originImage = result;
                }
                if (completion) {
                    completion(result, info);
                }
            }];
        }
    } else {
        if (completion) {
            completion([self originImage], nil);
        }
        return 0;
    }
}

 2. 缩略图

相对于在拉取原图时 ALAssetLibrary 的部分需要手工叠加系统相册的“编辑”效果,拉取缩略图则简单一些,因为系统接口拉取到的缩略图已经带上“编辑”的效果了。

.h 文件

/**
 *  Asset 的缩略图
 *
 *  @param size 指定返回的缩略图的大小,仅在 iOS 8.0 及以上的版本有效,其他版本则调用 ALAsset 的接口由系统返回一个合适当前平台的图片
 *
 *  @return Asset 的缩略图
 */
- (UIImage *)thumbnailWithSize:(CGSize)size;

/**
 *  异步请求 Asset 的缩略图,不会产生网络请求
 *
 *  @param size       指定返回的缩略图的大小,仅在 iOS 8.0 及以上的版本有效,其他版本则调用 ALAsset 的接口由系统返回一个合适当前平台的图片
 *  @param completion 完成请求后调用的 block,参数中包含了请求的缩略图以及图片信息,在 iOS 8.0 或以上版本中,这个 block 会被多次调用,
 *                    其中第一次调用获取到的尺寸很小的低清图,然后不断调用,直接获取到高清图,获取到高清图后 QMUIAsset 会缓存起这张高清图,
 *                    这时 block 中的第二个参数(图片信息)返回的为 nil。
 *
 *  @return 返回请求图片的请求 id
 */
- (NSInteger)requestThumbnailImageWithSize:(CGSize)size completion:(void (^)(UIImage *, NSDictionary *))completion;

.m 文件

- (UIImage *)thumbnailWithSize:(CGSize)size {
    if (_thumbnailImage) {
        return _thumbnailImage;
    }
    __block UIImage *resultImage;
    if (_usePhotoKit) {
        PHImageRequestOptions *phImageRequestOptions = [[PHImageRequestOptions alloc] init];
        phImageRequestOptions.resizeMode = PHImageRequestOptionsResizeModeExact;
            // 在 PHImageManager 中,targetSize 等 size 都是使用 px 作为单位,因此需要对targetSize 中对传入的 Size 进行处理,宽高各自乘以 ScreenScale,从而得到正确的图片
        [[[QMUIAssetsManager sharedInstance] phCachingImageManager] requestImageForAsset:_phAsset
                                                                              targetSize:CGSizeMake(size.width * ScreenScale, size.height * ScreenScale)
                                                                             contentMode:PHImageContentModeAspectFill options:phImageRequestOptions
                                                                           resultHandler:^(UIImage *result, NSDictionary *info) {
                                                                               resultImage = result;
                                                                           }];
    } else {
        CGImageRef thumbnailImageRef = [_alAsset thumbnail];
        if (thumbnailImageRef) {
            resultImage = [UIImage imageWithCGImage:thumbnailImageRef];
        }
    }
    _thumbnailImage = resultImage;
    return resultImage;
}

- (NSInteger)requestThumbnailImageWithSize:(CGSize)size completion:(void (^)(UIImage *, NSDictionary *))completion {
    if (_usePhotoKit) {
        if (_thumbnailImage) {
            if (completion) {
                completion(_thumbnailImage, nil);
            }
            return 0;
        } else {
            PHImageRequestOptions *imageRequestOptions = [[PHImageRequestOptions alloc] init];
            imageRequestOptions.resizeMode = PHImageRequestOptionsResizeModeExact;
            // 在 PHImageManager 中,targetSize 等 size 都是使用 px 作为单位,因此需要对targetSize 中对传入的 Size 进行处理,宽高各自乘以 ScreenScale,从而得到正确的图片
            return [[[QMUIAssetsManager sharedInstance] phCachingImageManager] requestImageForAsset:_phAsset targetSize:CGSizeMake(size.width * ScreenScale, size.height * ScreenScale) contentMode:PHImageContentModeAspectFill options:imageRequestOptions resultHandler:^(UIImage *result, NSDictionary *info) {
                // 排除取消,错误,低清图三种情况,即已经获取到了高清图时,把这张高清图缓存到 _thumbnailImage 中
                  BOOL downloadFinined = ![[info objectForKey:PHImageCancelledKey] boolValue] && ![info objectForKey:PHImageErrorKey] && ![[info objectForKey:PHImageResultIsDegradedKey] boolValue];
                  if (downloadFinined) {
                      _thumbnailImage = result;
                  }
                  if (completion) {
                      completion(result, info);
                  }
            }];
        }
    } else {
        if (completion) {
            completion([self thumbnailWithSize:size], nil);
        }
        return 0;
    }
}

 3. 预览图

与上面的方法类似,不再展开说明。

.h 文件

/**
 *  Asset 的预览图
 *
 *  @warning 仿照 ALAssetsLibrary 的做法输出与当前设备屏幕大小相同尺寸的图片,如果图片原图小于当前设备屏幕的尺寸,则只输出原图大小的图片
 *  @return Asset 的全屏图
 */
- (UIImage *)previewImage;

/**
 *  异步请求 Asset 的预览图,可能会有网络请求
 *
 *  @param completion        完成请求后调用的 block,参数中包含了请求的预览图以及图片信息,在 iOS 8.0 或以上版本中,
 *                           这个 block 会被多次调用,其中第一次调用获取到的尺寸很小的低清图,然后不断调用,直接获取到高清图,
 *                           获取到高清图后 QMUIAsset 会缓存起这张高清图,这时 block 中的第二个参数(图片信息)返回的为 nil。
 *  @param phProgressHandler 处理请求进度的 handler,不在主线程上执行,在 block 中修改 UI 时注意需要手工放到主线程处理。
 *
 *  @wraning iOS 8.0 以下中并没有异步请求预览图的接口,因此实际上为同步请求,这时 block 中的第二个参数(图片信息)返回的为 nil。
 *
 *  @return 返回请求图片的请求 id
 */
- (NSInteger)requestPreviewImageWithCompletion:(void (^)(UIImage *, NSDictionary *))completion withProgressHandler:(PHAssetImageProgressHandler)phProgressHandler;

 .m 文件

- (UIImage *)previewImage {
    if (_previewImage) {
        return _previewImage;
    }
    __block UIImage *resultImage;
    if (_usePhotoKit) {
        PHImageRequestOptions *imageRequestOptions = [[PHImageRequestOptions alloc] init];
        imageRequestOptions.synchronous = YES;
        [[[QMUIAssetsManager sharedInstance] phCachingImageManager] requestImageForAsset:_phAsset
                                                                            targetSize:CGSizeMake(SCREEN_WIDTH, SCREEN_HEIGHT)
                                                                           contentMode:PHImageContentModeAspectFill
                                                                               options:imageRequestOptions
                                                                         resultHandler:^(UIImage *result, NSDictionary *info) {
                                                                             resultImage = result;
                                                                         }];
    } else {
        CGImageRef fullScreenImageRef = [_alAssetRepresentation fullScreenImage];
        resultImage = [UIImage imageWithCGImage:fullScreenImageRef];
    }
    _previewImage = resultImage;
    return resultImage;
}

- (NSInteger)requestPreviewImageWithCompletion:(void (^)(UIImage *, NSDictionary *))completion withProgressHandler:(PHAssetImageProgressHandler)phProgressHandler {
    if (_usePhotoKit) {
        if (_previewImage) {
            // 如果已经有缓存的图片则直接拿缓存的图片
            if (completion) {
                completion(_previewImage, nil);
            }
            return 0;
        } else {
            PHImageRequestOptions *imageRequestOptions = [[PHImageRequestOptions alloc] init];
            imageRequestOptions.networkAccessAllowed = YES; // 允许访问网络
            imageRequestOptions.progressHandler = phProgressHandler;
            return [[[QMUIAssetsManager sharedInstance] phCachingImageManager] requestImageForAsset:_phAsset targetSize:CGSizeMake(SCREEN_WIDTH, SCREEN_HEIGHT) contentMode:PHImageContentModeAspectFill options:imageRequestOptions resultHandler:^(UIImage *result, NSDictionary *info) {
                // 排除取消,错误,低清图三种情况,即已经获取到了高清图时,把这张高清图缓存到 _previewImage 中
                BOOL downloadFinined = ![[info objectForKey:PHImageCancelledKey] boolValue] && ![info objectForKey:PHImageErrorKey] && ![[info objectForKey:PHImageResultIsDegradedKey] boolValue];
                if (downloadFinined) {
                    _previewImage = result;
                }
                if (completion) {
                    completion(result, info);
                }
            }];
        }
    } else {
        if (completion) {
            completion([self previewImage], nil);
        }
        return 0;
    }
}

 4. 方向(imageOrientation)

比较奇怪的是,无论在 PhotoKit 或者是 ALAssetLibrary 中,要想获取到准确的图像方向,只能通过某些 key 检索所得。

.h 文件

- (UIImageOrientation)imageOrientation;

.m 文件

- (UIImageOrientation)imageOrientation {
    UIImageOrientation orientation;
    if (_usePhotoKit) {
        if (!_phAssetInfo) {
            // PHAsset 的 UIImageOrientation 需要调用过 requestImageDataForAsset 才能获取
            [self requestPhAssetInfo];
        }
        // 从 PhAssetInfo 中获取 UIImageOrientation 对应的字段
        orientation = (UIImageOrientation)[_phAssetInfo[@"orientation"] integerValue];
    } else {
        orientation = (UIImageOrientation)[[_alAsset valueForProperty:@"ALAssetPropertyOrientation"] integerValue];
    }
    return orientation;
}

参考资料:

objc中国 - 照片框架

Example app using Photos framework

AssetsLibrary Framework Reference

]]>
https://kayosite.com/ios-development-and-detail-of-photo-framework-part-three.html/feed 9 3486