工程化 | Kayo's Melody https://kayosite.com Practice Makes Perfect Tue, 31 Aug 2021 14:33:09 +0000 zh-CN hourly 1 https://cdn.kayosite.com/wp-content/uploads/2021/08/image.jpg?imageMogr2/thumbnail/!64x64r|imageMogr2/gravity/Center/crop/64x64 工程化 | Kayo's Melody https://kayosite.com 32 32 115630942 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