Kayo's Melody http://kayosite.com Practice Makes Perfect Mon, 16 Oct 2017 12:52:47 +0000 zh-CN hourly 1 Android Lint 实践之二 —— 自定义 Lint http://kayosite.com/android-lint-custom-issue.html http://kayosite.com/android-lint-custom-issue.html#respond Mon, 16 Oct 2017 06:02:55 +0000 http://kayosite.com/?p=3621 背景

如前文《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 等信息。

image_1bs5oregt1ctfajapnr1rc412bp9.png-72.7kB

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。

参考资料

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

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)输出结果(如下图)。

image_1bs08q1co1i8vn4p1loi1dd21m9k9.png-35.7kB

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

image_1bs0ioo4fuaa1d8n10devkm131013.png-102.7kB

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

image_1bs0ithb2its4jjt20u4i8951g.png-209.1kB

如上图所展示的,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)、解决方案或者屏蔽提示:

image_1bs2nvgrk1k249m51fvh1c5510129.png-71.5kB

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

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

image_1bs35qbi9rnd19gt1cr71d331hf4m.png-55.7kB

配置

对于执行 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 示例如下:

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

]]>
http://kayosite.com/android-lint-intro-and-frequently-asked-questions.html/feed 0
腾讯 Web UI 解决方案 QMUI Web —— 探索与沉淀 http://kayosite.com/tencent-web-ui-solution-qmui-web.html http://kayosite.com/tencent-web-ui-solution-qmui-web.html#respond Fri, 08 Sep 2017 10:54:46 +0000 http://kayosite.com/?p=3599 经过长时间的打磨迭代,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 的服务开启/关闭,并提供了编译提醒,出错提醒,进程关闭提醒等额外的功能,在处理多项目,多分支时能更方便地进行开发。

优化和开源

在经历较长时间的迭代后,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

]]>
http://kayosite.com/tencent-web-ui-solution-qmui-web.html/feed 0
QMUI iOS UI 框架正式发布 http://kayosite.com/qmui-ios.html http://kayosite.com/qmui-ios.html#comments Sun, 05 Feb 2017 09:49:40 +0000 http://kayosite.com/?p=3589 1.png-317.9kB

QMUI iOS

QMUI iOS 是一个致力于提高项目 iOS UI 开发效率的解决方案,其设计目是用于辅助快速搭建一个具备基本设计还原效果的 iOS 项目,同时利用自身提供的丰富控件及兼容处理,让开发者能专注于业务需求而无需耗费精力在基础代码的设计上。不管是新项目的创建,或是已有项目的维护,均可使开发效率和项目质量得到大幅度提升。

官网:http://qmuiteam.com/ios

GitHub 源码:https://github.com/QMUI/QMUI_iOS

GitHub Demo:https://github.com/QMUI/QMUIDemo_iOS

App Store 下载 Demo:
DownloadQRCode_2x.png-2.2kB

框架特性

全局 UI 配置:只需要修改一份配置表就可以调整 App 的全局样式,包括颜色、导航栏、输入框、列表等。一处修改,全局生效。

UIKit 拓展及版本兼容:拓展多个 UIKit 的组件,提供更加丰富的特性和功能,提高开发效率;解决不同 iOS 版本常见的兼容性问题。

丰富的 UI 控件:提供丰富且常用的 UI 控件,使用方便灵活,并且支持自定义控件的样式。

高效的工具方法及宏:提供高效的工具方法,包括设备信息、动态字体、键盘管理、状态栏管理等,可以解决各种常见场景并大幅度提升开发效率。

如何使用

快速开始:http://qmuiteam.com/ios/page/start.html

功能列表文档:http://qmuiteam.com/ios/page/document.html

Demo 效果预览

3.gif-1926.1kB

意见反馈

如果有任何的意见或者建议,欢迎直接留言或者在 Github 创建 issue,感谢你的支持和贡献。

]]>
http://kayosite.com/qmui-ios.html/feed 9
Gulp 结构化最佳实践 http://kayosite.com/gulp-module-best-practice.html http://kayosite.com/gulp-module-best-practice.html#comments Tue, 23 Aug 2016 14:09:42 +0000 http://kayosite.com/?p=3586 在 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(&#039;del&#039;);

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

gulpfile.js

var gulp        = require(&#039;gulp&#039;),
    requireDir  = require(&#039;require-dir&#039;);

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

gulp.task(&#039;default&#039;, [&#039;clean&#039;]);

这种方法操作起来比较简单,同时基本不需要改动原有的代码,只需对 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(&#039;gulp-load-plugins&#039;)({
                    rename: {
                      &#039;gulp-file-include&#039;: &#039;include&#039;,
                      &#039;gulp-merge-link&#039;: &#039;merge&#039;
                    }
                  }),
    packageInfo = require(&#039;../package.json&#039;),
    lib         = require(&#039;./lib.js&#039;),
    config      = require(&#039;./config.js&#039;);;

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

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

module.exports = common;

clean.js

var del = require(&#039;del&#039;);

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

gulpfile.js

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

// 载入任务
var taskPath = &#039;workflow/task&#039;;

fs.readdirSync(taskPath).filter(function (file) {
  return file.match(/js$/); // 排除非 JS 文件,如 Vim 临时文件
}).forEach(function (_file) {
  require(&#039;./&#039; + taskPath + &#039;/&#039; + _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://www.npmjs.com/package/gulp-load-plugins] 插件管理多个插件。
]]>
http://kayosite.com/gulp-module-best-practice.html/feed 9
SassDoc 详细介绍与最佳实践 http://kayosite.com/sassdoc-intro-and-best-best-practices.html http://kayosite.com/sassdoc-intro-and-best-best-practices.html#respond Mon, 22 Aug 2016 09:31:22 +0000 http://kayosite.com/?p=3579 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=&#039;#{ie-hex-str($start-color)}&#039;, endColorstr=&#039;#{ie-hex-str($end-color)}&#039;, 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 的选项:

  • 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(&#039;readToolMethod&#039;, false, function(){
  var sassdoc = require(&#039;sassdoc&#039;);

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

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

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

数据分组

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

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

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

        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 = &#039;./qmui_tools.json&#039;;

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

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

重新拼接方法体

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

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

var makeCompleteMethodWithItem = function(item) {
  var result = &#039;&#039;,
      itemType = null;

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

  result = result + itemType + item.context.name;
  if (item.parameter) {
    result += &#039;(&#039;;

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

  return result;
};

SassSDocMeister

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

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

]]>
http://kayosite.com/sassdoc-intro-and-best-best-practices.html/feed 0
QMUI 团队 Web 前端框架正式发布 http://kayosite.com/qmui_web.html http://kayosite.com/qmui_web.html#comments Wed, 03 Aug 2016 13:36:51 +0000 http://kayosite.com/?p=3563 webOfficialPreview

QMUI Web

一个旨在提高 UI 开发效率、快速产生项目 UI 的前端框架,由腾讯 QMUI 团队出品。

Github: https://github.com/QMUI/qmui_web
官网:http://qmuiteam.com/web

QMUI Web 是一个专注 Web UI 开发,帮助开发者快速实现特定的一整套设计的框架。框架主要由一个强大的 SASS 方法合集与内置的工作流构成。通过 QMUI Web,开发者可以很轻松地提高 Web UI 开发的效率,同时保持了项目的高可维护性与稳健。如果你需要方便地控制项目的整体样式,或者需要应对频繁的界面变动,那么 QMUI Web 框架将会是你最好的解决方案。

功能特性

基础配置与组件

通过内置的公共组件和对应的 SASS 配置表,你只需修改简单的配置即可快速实现所需样式的组件。(QMUI SASS 配置表和公共组件如何帮忙开发者快速搭建项目基础 UI?

SASS 与 Compass 支持

QMUI Web 包含70个 SASS mixin/function/extend,涉及布局、外观、动画、设备适配、数值计算以及 SASS 原生能力增强等多个方面,可以大幅提升开发效率。

脚手架

QMUI Web 内置的工作流拥有从初始化项目到变更文件的各种自动化处理,包含了模板引擎,图片集中管理与自动压缩,静态资源合并、压缩与变更以及冗余文件清理等功能。

扩展组件

QMUI Web 除了内置的公共组件外,还通过扩展的方式提供了常用的扩展组件,如雪碧图组件,等高左右双栏,文件上传按钮,树状选择菜单。

环境配置

#安装 gulp
npm install --global gulp
#安装 SASS
gem install sass
#安装 Compass
gem update --systemgem install compass

为什么采用原生 SASS 和 Compass?

快速开始

推荐使用 Yeoman 脚手架 generator-qmui 安装和配置 QMUI Web。该工具可以帮助你完成 QMUI Web 的所有安装和配置。

#安装 Yeoman,如果本地已安装可以忽略
npm install -g yo
#安装 QMUI 的模板
npm install -g generator-qmui
#在项目根目录执行以下命令
yo qmui

效果预览

其他说明

推荐配合桌面 App:QMUI Web Desktop。它可以管理基于 QMUI Web 进行开发的项目,并提供了编译提醒,出错提醒,进程关闭提醒等额外的功能。

QMUI Web Desktop

意见反馈

如果有意见反馈或者功能建议,欢迎创建 Issue 或发送 Pull Request,感谢你的支持和贡献。

]]>
http://kayosite.com/qmui_web.html/feed 1
博客近况 http://kayosite.com/blog-recent.html http://kayosite.com/blog-recent.html#comments Fri, 06 May 2016 12:27:21 +0000 http://kayosite.com/?p=3551 一. 主机

去年开始断断续续地更换了几次主机,始终还是不稳定,于是最近下了决心,备案域名,把博客迁到阿里云的国内主机。

幸好一直有做备份,迁移文件倒也不费时,但由于原主机与阿里云 MySQL 的版本相差比较大,而且博客的数据量也不少,所以迁移数据时费了一些时间,总算把所有数据迁移过来。

毕竟这是个长年长草的博客了,最近都没更新,以前的文章可不想丢失了。

二. 主题

已经三年都没有换主题了,现在回头看来,主题当时做得有很多不足 ——

  • PHP 中有大量的低性能代码,主要就是 WP 的各种 Query 重复调用,现在好好利用 hook,修改原有的方法,减少冗余消耗。但 hook 这套机制用起来好像也不怎么实在。
  • HTML + CSS 完全重写了,这两年都在做 UI 开发,这块自然是我最想重构的。
  • 除了代码,UI 本身也做了一些调整,会让版面更加合理和实用。
  • 两年前抛弃了 jQuery 后自己写了一个前端库,这次顺便修复了其中的 Bugs。
  • 主题文件都是直接修改后放置到服务器,现在加入了变更流程,主题修改后会按照完备的上线流程处理,静态资源也会分发到 CDN。
  • 想了很久,不再兼容 IE6/7。

除了这些,之前断续有记下博客的优化点,这次一一改过来。

三. 世界上最好的语言

那个用 Node.js 代替 PHP 重写 WP 的文章如果是真的话多好!

]]>
http://kayosite.com/blog-recent.html/feed 18
iOS 开发之照片框架详解之二 —— PhotoKit 详解(下) http://kayosite.com/ios-development-and-detail-of-photo-framework-part-three.html http://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;
}

系列文章:
iOS 开发之照片框架详解
iOS 开发之照片框架详解之二 —— PhotoKit 详解(上)
iOS 开发之照片框架详解之二 —— PhotoKit 详解(下)

参考资料:
objc中国 - 照片框架
Example app using Photos framework
AssetsLibrary Framework Reference

]]>
http://kayosite.com/ios-development-and-detail-of-photo-framework-part-three.html/feed 27
iOS 开发之照片框架详解之二 —— PhotoKit 详解(上) http://kayosite.com/ios-development-and-detail-of-photo-framework-part-two.html http://kayosite.com/ios-development-and-detail-of-photo-framework-part-two.html#comments Mon, 21 Sep 2015 13:05:41 +0000 http://kayosite.com/?p=3479 一. 概况

本文接着?iOS 开发之照片框架详解,侧重介绍在前文中简单介绍过的 PhotoKit 及其与 ALAssetLibrary 的差异,以及如何基于 PhotoKit 与 AlAssetLibrary 封装出通用的方法。

这里引用一下前文中对 PhotoKit 基本构成的介绍:

  • PHAsset: 代表照片库中的一个资源,跟 ALAsset 类似,通过 PHAsset 可以获取和保存资源
  • PHFetchOptions: 获取资源时的参数,可以传 nil,即使用系统默认值
  • PHAssetCollection: PHCollection 的子类,表示一个相册或者一个时刻,或者是一个「智能相册(系统提供的特定的一系列相册,例如:最近删除,视频列表,收藏等等,如下图所示)
  • PHFetchResult: 表示一系列的资源结果集合,也可以是相册的集合,从?PHCollection 的类方法中获得
  • PHImageManager: 用于处理资源的加载,加载图片的过程带有缓存处理,可以通过传入一个 PHImageRequestOptions 控制资源的输出尺寸等规格
  • PHImageRequestOptions: 如上面所说,控制加载图片时的一系列参数

这里还有一个额外的概念?PHCollectionList,表示一组?PHCollection,它本身也是一个?PHCollection,因此?PHCollection 作为一个集合,可以包含其他集合,这使到 PhotoKit 的组成比 ALAssetLibrary 要复杂一些。另外与 ALAssetLibrary 相似,一个 PHAsset 可以同时属于多个不同的 PHAssetCollection,最常见的例子就是刚刚拍摄的照片,至少同时属于“最近添加”、“相机胶卷”以及“照片 - 精选”这三个 PHAssetCollection。关于这几个概念的关系如下图:

ios8-photo-kit

二. ?PhotoKit 的机制

1. 获取资源

在 ALAssetLibrary 中获取数据,无论是相册,还是资源,本质上都是使用枚举的方式,遍历照片库取得相应的数据,并且数据是从 ALAssetLibrary(照片库) - ALAssetGroup(相册)- ALAsset(资源)这一路径逐层获取,即使有直接从 ALAssetLibrary 这一层获取 ALAsset 的接口,本质上也是枚举 ALAssetLibrary 所得,并不是直接获取,这样的好处很明显,就是非常符合实际应用中资源的显示路径:照片库 - 相册 - 图片或视频,但由于采用枚举的方式获取资源,效率低而且不灵活。

而在 PhotoKit 中,则是采用“获取”的方式拉取资源,这些获取的手段,都是一系列形如 class func fetchXXX(..., options: PHFetchOptions) -> PHFetchResult 的类方法,具体使用哪个类方法,则视乎需要获取的是相册、时刻还是资源,这类方法中的 option 充当了过滤器的作用,可以过滤相册的类型,日期,名称等,从而直接获取对应的资源而不需要枚举。例如在前文中列举个的几个小例子:

// 列出所有相册智能相册
PHFetchResult *smartAlbums = [PHAssetCollection fetchAssetCollectionsWithType:PHAssetCollectionTypeSmartAlbum subtype:PHAssetCollectionSubtypeAlbumRegular options:nil];
 
// 列出所有用户创建的相册
PHFetchResult *topLevelUserCollections = [PHCollectionList fetchTopLevelUserCollectionsWithOptions:nil];
 
// 获取所有资源的集合,并按资源的创建时间排序
PHFetchOptions *options = [[PHFetchOptions alloc] init];
options.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:YES]];
PHFetchResult *assetsFetchResults = [PHAsset fetchAssetsWithOptions:options];

如前面提到过的那样,从?PHAssetCollection 获取中获取到的可以是相册也可以是资源,但无论是哪种内容,都统一使用?PHFetchResult 对象封装起来,因此虽然 PHAssetCollection 获取到的结果可能是多样的,但通过?PHFetchResult 就可以使用统一的方法去处理这些内容(即遍历 PHFetchResult)。例如扩展上面的例子:

// 列出所有相册智能相册
PHFetchResult *smartAlbums = [PHAssetCollection fetchAssetCollectionsWithType:PHAssetCollectionTypeSmartAlbum subtype:PHAssetCollectionSubtypeAlbumRegular options:nil];
// 这时 smartAlbums 中保存的应该是各个智能相册对应的 PHAssetCollection
for (NSInteger i = 0; i < fetchResult.count; i++) {
    // 获取一个相册(PHAssetCollection)
    PHCollection *collection = fetchResult[i];
    if ([collection isKindOfClass:[PHAssetCollection class]]) {
        PHAssetCollection *assetCollection = (PHAssetCollection *)collection;
        // 从每一个智能相册中获取到的 PHFetchResult 中包含的才是真正的资源(PHAsset)
        PHFetchResult *fetchResult = [PHAsset fetchAssetsInAssetCollection:assetCollection options:fetchOptions];
    else {
        NSAssert(NO, @"Fetch collection not PHCollection: %@", collection);
    }
}
 
// 获取所有资源的集合,并按资源的创建时间排序
PHFetchOptions *options = [[PHFetchOptions alloc] init];
options.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:YES]];
PHFetchResult *assetsFetchResults = [PHAsset fetchAssetsWithOptions:options];
// 这时 assetsFetchResults 中包含的,应该就是各个资源(PHAsset)
for (NSInteger i = 0; i < fetchResult.count; i++) {
    // 获取一个资源(PHAsset)
    PHAsset *asset = fetchResult[i];
}

?2. 获取图像的方式与坑点

经过了上面的步骤,已经可以了解到如何在 PhotoKit 中获取到代表资源的 PHAsset 了,但与 ALAssetLibrary 中从 ALAsset 中直接获取图像的方式不同,PhotoKit 无法直接从 PHAsset 的实例中获取图像,而是引入了一个管理器?PHImageManager 获取图像。PHImageManager 是通过请求的方式拉取图像,并可以控制请求得到的图像的尺寸、剪裁方式、质量,缓存以及请求本身的管理(发出请求、取消请求)等。而请求图像的方法是 ?PHImageManager 的一个实例方法:

- (PHImageRequestID)requestImageForAsset:(PHAsset *)asset 
                              targetSize:(CGSize)targetSize 
                             contentMode:(PHImageContentMode)contentMode 
                                 options:(nullable PHImageRequestOptions *)options 
                           resultHandler:(void (^)(UIImage *__nullable result, NSDictionary *__nullable info))resultHandler;

这个方法中的参数坑点不少,下面逐个参数列举一下其作用及坑点:

  • asset,图像对应的 PHAsset。
  • targetSize,需要获取的图像的尺寸,如果输入的尺寸大于资源原图的尺寸,则只返回原图。需要注意在 PHImageManager 中,所有的尺寸都是用 Pixel 作为单位(Note that all sizes are in pixels),因此这里想要获得正确大小的图像,需要把输入的尺寸转换为 Pixel如果需要返回原图尺寸,可以传入 PhotoKit 中预先定义好的常量?PHImageManagerMaximumSize,表示返回可选范围内的最大的尺寸,即原图尺寸。
  • contentMode,图像的剪裁方式,与?UIView 的 contentMode 参数相似,控制照片应该以按比例缩放还是按比例填充的方式放到最终展示的容器内。注意如果 targetSize 传入?PHImageManagerMaximumSize,则 contentMode 无论传入什么值都会被视为?PHImageContentModeDefault
  • options,一个?PHImageRequestOptions 的实例,可以控制的内容相当丰富,包括图像的质量、版本,也会有参数控制图像的剪裁,下面再展开说明。
  • resultHandler,请求结束后被调用的 block,返回一个包含资源对于图像的 UIImage 和包含图像信息的一个 NSDictionary,在整个请求的周期中,这个 block 可能会被多次调用,关于这点连同 options 参数在下面展开说明

(1)PHImageRequestOptions 与 iCloud 照片库

PHImageRequestOptions 中包含了一系列控制请求图像的属性。

resizeMode 属性控制图像的剪裁,不知道为什么 PhotoKit 会在请求图像方法(requestImageForAsset)中已经有控制图像剪裁的参数后(contentMode),还在 options 中加入控制剪裁的属性,但如果两个地方所控制的剪裁结果有所冲突,PhotoKit 会以 resizeMode 的结果为准。另外,resizeMode 也有控制图像质量的作用。如?resizeMode?设置为?PHImageRequestOptionsResizeModeExact 则返回图像必须和目标大小相匹配,并且图像质量也为高质量图像,而设置为 PHImageRequestOptionsResizeModeFast 则请求的效率更高,但返回的图像可能和目标大小不一样并且质量较低。

在 PhotoKit 中,对 iCloud 照片库有很好的支持,如果用户开启了 iCloud 照片库,并且选择了“优化 iPhone/iPad 储存空间”,或者选择了“下载并保留原件”但原件还没有加载好的时候,PhotoKit 也会预先拿到这些非本地图像的 PHAsset,但是由于本地并没有原图,所以如果产生了请求高清图的请求,PHotoKit 会尝试从 iCloud 下载图片,而这个行为最终的表现,会被?PHImageRequestOptions 中的值所影响。PHImageRequestOptions 中常常会用的几个属性如下:

networkAccessAllowed 参数控制是否允许网络请求,默认为 NO,如果不允许网络请求,那么就没有然后了,当然也拉取不到 iCloud 的图像原件。deliveryMode 则用于控制请求的图片质量。synchronous 控制是否为同步请求,默认为 NO,如果?synchronous 为 YES,即同步请求时,deliveryMode 会被视为 PHImageRequestOptionsDeliveryModeHighQualityFormat,即自动返回高质量的图片,因此不建议使用同步请求,否则如果界面需要等待返回的图像才能进一步作出反应,则反应时长会很长。

还有一个与 iCloud 密切相关的属性?progressHandler,当图像需要从 iCloud 下载时,这个 block 会被自动调用,block 中会返回图像下载的进度,图像的信息,出错信息。开发者可以利用这些信息反馈给用户当前图像的下载进度以及状况,但需要注意?progressHandler 不在主线程上执行,因此在其中需要操作 UI,则需要手工放到主线程执行。

上面有提到,requestImageForAsset 中的参数?resultHandler 可能会被多次调用,这种情况就是图像需要从 iCloud 中下载的情况。在?requestImageForAsset 返回的内容中,一开始的那一次请求中会返回一个小尺寸的图像版本,当高清图像还在下载时,开发者可以首先给用户展示这个低清的图像版本,然后 block 在多次调用后,最终会返回高清的原图。至于当前返回的图像是哪个版本的图像,可以通过 block 返回的 NSDictionary info 中获知,PHImageResultIsDegradedKey 表示当前返回的 UIImage 是低清图。如果需要判断是否已经获得高清图,可以这样判断:

// 排除取消,错误,低清图三种情况,即已经获取到了高清图
BOOL downloadFinined = ![[info objectForKey:PHImageCancelledKey] boolValue] && ![info objectForKey:PHImageErrorKey] && ![[info objectForKey:PHImageResultIsDegradedKey] boolValue];

另外,当我们使用?requestImageForAsset 发出对图像的请求时,如果在同一个 PHImageManager 中同时对同一个资源发出图像请求,请求的进度是可以共享的,因此我们可以利用这个特性,把 PHImageManager 以单例的形式使用,这样在切换界面时也不用担心无法传递图像的下载进度。例如,在图像的列表页面触发了下载图像,当我们离开列表页面进入预览大图界面时,并不用担心会重新图像会重新下载,只要没有手工取消图像下载,进入预览大图界面下载图像会自动继续从上次的进度下载图像。

如果希望取消下载图像,则可以使用?PHImageManager 的 ?cancelImageRequest 方法,它传入的是请求图像的请求 ID,这个 ID 可以从?requestImageForAsset 的返回值中获得,也可以从前面提到的包含图像信息的?NSDictionary info 中获得,当然前提是这个这个接收取消请求的 PHImageManager 与刚刚发出请求的 PHImageManager 是同一个实例,如上面所述使用单例是最为简单有效的方式。

最后,还要介绍一个?PHImageRequestOptions 的属性 versions,这个属性是指获取的图像是否需要包含系统相册“编辑”功能处理过的信息(如滤镜,旋转等),这一点比 ALAssetLibrary 要灵活很多,ALAssetLibrary 中并不能灵活地控制获取的图像是否带有“编辑”处理过的效果,例如在 ALAsset 中获取原图的接口 fullResolutionImage 获取到的是不带“编辑”效果的图像,要想获取带有“编辑”效果的图像,只能自行处理获取这些滤镜效果,并手工叠加上去。在我们的 UI 框架 QMUI 中就有对获取原图作出这样的封装,整个过程也较为繁琐,而框架中处理 PhotoKit 的部分则灵活很多,这也体现了 PhotoKit 相比 ALAssetLibrary 的最主要特点——复杂但灵活。文章的第三部分也会详细列出如何处理这个问题。

(2)获取图像的优化

PHImageManager 提供了一个子类?PHImageCachingManager 用于处理图像的缓存,但是这个子类并不只是图像本身的缓存,而是更加实用——处理图像的整个加载过程的缓存。例如要在一个?collectionView 上展示图像列表这类大量的资源图像的缩略图时,可以利用 PHImageCachingManager?预先将一些图像加载到内存中,这对优化 collectionView 滚动时的表现很有帮助。然而,这只是官方说法,实际上由于加载图像的过程并不确定,每个业务加载图像的实际需求都可能不一样,因此?PHImageCachingManager 也采用比较松散的方法去控制这些缓存,其中的关键方法:

- (void)startCachingImagesForAssets:(NSArray<PHAsset *> *)assets targetSize:(CGSize)targetSize contentMode:(PHImageContentMode)contentMode options:(nullable PHImageRequestOptions *)options;

需要传入一组 PHAsset,以及 targetSize,contentMode,以及一个?PHImageRequestOptions,如上面所述,这些参数之间的有着互相影响的作用,因此实际上不同的场景对于每个参数要求都不一样,而这些参数的最佳取值也只能通过实际在场景中测试所得。因此,比起使用?PHImageCachingManager,我总结了一些更为简易可行的缓存方法:

  • 获取图片时尽量获取预览图,不要直接显示原件,建议获取与设备屏幕同样大小的图像即可,实际上系统相册预览大图时使用的也是预览图,这也是系统相册加载速度快的原因。
  • 获取图片使用异步请求,如上面所述,当请求为异步时返回图像的 block 会被多次调用,先返回低清图,再返回高清图,这样一来可以大大减少 UI 的等待时间。
  • 获取到高清图后可以缓存下来,简单地使用变量缓存即可,尽量在获取到高清图后避免再次发起请求获取图像。因为即使图像原件已经下载下来,重新请求高清图时因为图片的尺寸比较大,因此系统生成图像和剪裁图像也会花费一些时间。
  • 预先加载图像,如像预览大图这类情景中,用户同时只会看到一张大图,因此在观看某一张图片时,预先请求其邻近两张图片,对于加快 UI 的响应很有帮助。

经过实际测试,如果请求的是缩略图(即尺寸小的图像),那么即使请求的图像很多,仍不会产生任何不流畅的表现,但如果请求的是高清大图,那么即使只是同时请求几张图都会产生不流畅的状况。如上面提到过的那样,这些的状况的出现很可能是请求大图时由图片元数据产生图像,以及剪裁图像的过程耗时较多。所以按实际表现来看,即使 PhotoKit 有自己的缓存策略,仍然很难避免这部分耗时。因此上面几点优化获取图像的策略重点也是放在减少图像大小,异步请求以及做缓存几个方面。

文章的第三部分——如何基于 PhotoKit 与 AlAssetLibrary 封装出通用的方法,请浏览?iOS 开发之照片框架详解之二 —— PhotoKit 详解(下)

系列文章:
iOS 开发之照片框架详解
iOS 开发之照片框架详解之二 —— PhotoKit 详解(上)
iOS 开发之照片框架详解之二 —— PhotoKit 详解(下)

参考资料:
objc中国 - 照片框架
Example app using Photos framework
AssetsLibrary Framework Reference

]]>
http://kayosite.com/ios-development-and-detail-of-photo-framework-part-two.html/feed 13