Android屏幕适配方案: AutoDensity&smallest-width

目录

    • 前言
    • 一、屏幕适配的重要概念
      • 1.1 屏幕尺寸、屏幕分辨率、屏幕像素密度
      • 1.2 px、dp、dip、dpi
      • 1.3 mdpi、hdpi、xdpi、xxdpi
      • 1.4 values-sw[xyz]dp
    • 二、smallest-width适配方案
    • 三、AutoDensity适配方案
    • 四、最佳做法
    • 五、参考

前言

一个Android开发工程师,在其入门后遇到的第一个考验估计就是屏幕适配。按照谷歌的适配规则,使用wrap_content、match_parent、dp等,当UI工程师换一个设备验收时,提出各种问题。这时候,估计很多人一脸懵逼,难道谷歌的适配标准是错误的?其实不是,只是还不够。android屏幕碎片化问题,导致很难一次性适配所有屏幕,这就导致了百分比适配方案的刚需。因为它用最低的成本适配了尽可能多的设备。最终今日头条屏幕适配方案和最小宽度适配方案,经过考验成为最受欢迎的两种适配方案。本文在研究了smallest-width和今日头条适配方案的基础上,用kotlin实现了smallest-width的dimens生成工具,用kotlin实现了今日头条适配方案:AutoDensity。并提出,最佳实践方案:布局和代码中用一份1:1的dimens,实际用AutoDensity一行代码实现适配,这样进可攻,退可守,在开发过程中可以无缝切换,直到正式发第一个版本时,可以选择其中一个,或者仍保留两个。

一、屏幕适配的重要概念

在开始屏幕适配之前,至少对下面几个概念有个简单理解。

1.1 屏幕尺寸、屏幕分辨率、屏幕像素密度

屏幕尺寸是指屏幕的对角线物理长度,单位英寸,1英寸=2.54厘米。如4.7英寸,5.5英寸,5.8英寸…

屏幕分辨率是指屏幕x&y轴上的物理像素值,如1920*1080。

屏幕像素密度是指单位英寸上的物理像素点数,一般是指对角线上。屏幕像素密度与屏幕尺寸和屏幕分辨率有关,在单一变化条件下,屏幕尺寸越小、分辨率越高,像素密度越大,反之越小。

1.2 px、dp、dip、dpi

px也就是上面说的像素;dp和dip,即密度无关像素;dpi,也就是上面说的屏幕像素密度,假如一英寸里面有160个像素,这个屏幕的像素密度就是160dpi。在Android中,规定以160dpi为基准,1dip=1px,如果密度是320dpi,则1dip=2px,以此类推。

1.3 mdpi、hdpi、xdpi、xxdpi

了解了屏幕像素密度dpi,需要了解android中的mdpi,hdpi,xdpi,xxdpi…它们描述的是dp和px的关系,用两张表来说明:

名称 像素密度范围
mdpi 120dpi~160dpi
hdpi 160dpi~240dpi
xhdpi 240dpi~320dpi
xxhdpi 320dpi~480dpi
xxxhdpi 480dpi~640dpi
屏幕密度 图标尺寸
mdpi 48x48px
hdpi 72x72px
xhdpi 96x96px
xxhdpi 144x144px
xxxhdpi 192x192px

在计算dp和px时,mdpi是基准1比1,hdpi是1比1.5,xhdpi是1比2,以此类推。所以当UI给你设计稿时,一般可能只有像素,如1080*720,这时候还需要知道比例,也就是像素密度才能转化为Android用的dp单位。一般UI设计师也不知道,是多少比例,常用的是1比2。不过现在有很多工具是直接给dp单位的,如蓝湖,这就很方便来。

1.4 values-sw[xyz]dp

最小宽度限定,这就是smallest-width运行的基础。app在运行的时候会根据屏幕的最短边,选择对应适当的values-sw[xyz]dp目录里面的dimens。

二、smallest-width适配方案

如上面所说,app在运行的时候会根据屏幕的最短边,选择对应适当的values-sw[xyz]dp目录里面的dimens。这就是smallest-width适配方案的基础,系统具体是如何实现的,这里不做分析,只要知道这个结果,然后我们可以生成一系列的dimens,放在一系列的values-sw[xyz]dp目录,这样就达到来百分比布局的效果。
使用smallest-width作为适配方案,有一定的代码侵入,也就是要在布局文件中插入如:@dimen/dp48,这样的代码,也仅此而已,没有更多代码需要写。但是生成不同的dimens文件,这里给出一个kotlin写的工具,同时这个工具还提供方法,将现有布局文件中的dp值,改为生成的dimen对应值,不过请在了解它和自身的需求后慎重使用。

  1. DimenTypes
enum class DimenTypes(val smallestWith: Int) {
    SW_DP_300(300),
    SW_DP_310(310),
    SW_DP_320(320),
    SW_DP_330(330),
    SW_DP_340(340),
    SW_DP_350(350),
    SW_DP_360(360),
    SW_DP_370(370),
    SW_DP_375(375),
    SW_DP_380(380),
    SW_DP_390(390),
    SW_DP_400(400),
    SW_DP_410(410),
    SW_DP_420(420),
    SW_DP_430(430),
    SW_DP_440(440),
    SW_DP_450(450),
    SW_DP_460(460)
}
  1. DimenGenerator
package com.bottle.core.arch.smallest

import com.bottle.core.utils.domToXmlFile
import com.bottle.core.utils.writeFile
import java.io.BufferedReader
import java.io.File
import java.io.FileInputStream
import java.io.InputStreamReader
import java.math.BigDecimal
import java.util.regex.Pattern
import javax.xml.parsers.DocumentBuilderFactory

/**
 * 最大值
 */
private const val MAX_VALUE = 720

/**
 * 设计稿尺寸(将自己设计师的设计稿的宽度填入)
 */
private const val DESIGN_WIDTH = 375

/**
 * 设计稿的高度  (将自己设计师的设计稿的高度填入)
 */
private const val DESIGN_HEIGHT = 667

/**
 * 执行这个方法,将会在core模块的res目录下生成一系列的values-sw的dimens.xml
 */
fun main(args: Array<String>) {
   val generate = true
    if (generate) {
        generate()
    } else {
        layoutFileCompat()
    }
}

fun generate() {
    val smallest = DESIGN_WIDTH.coerceAtMost(DESIGN_HEIGHT)
    val values = DimenTypes.values()
    var sb: StringBuilder
    val rootPath = File("")
    val basePath = rootPath.absolutePath
    for (value in values) {
        sb = StringBuilder()
        sb.append(basePath).append(File.separator)
            .append("core").append(File.separator)
            .append("src").append(File.separator)
            .append("main").append(File.separator)
            .append("res")
        val path = sb.toString()
        makeAll(smallest, value, path, "values-sw${value.smallestWith}dp")
    }
}

fun makeAll(sw: Int, dimen: DimenTypes, resPath: String, folder: String) {
    val valueFile = File(resPath + File.separator + folder)
    if (!valueFile.exists()) {
        valueFile.mkdirs()
    }
    val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument()
    val root = document.createElement("resources")
    val power = dimen.smallestWith / (sw * 1.0F)
    for (i in 1..MAX_VALUE) {
        val dimenElement = document.createElement("dimen")
        val dpValue = (i * power).toDouble()
        val bigDecimal = BigDecimal(dpValue)
        val finDp = bigDecimal.setScale(2, BigDecimal.ROUND_HALF_UP).toFloat()
        dimenElement.textContent = "${finDp}dp"
        dimenElement.setAttribute("name", "dp$i")
        root.appendChild(dimenElement)
    }
    document.appendChild(root)
    val dimenFile = resPath + File.separator + folder + File.separator + "dimens.xml"
    domToXmlFile(document, dimenFile)
}


private const val regDimen = "@dimen/dp"

/**
 * 将现有的布局文件中的xydp替换成dpxy,慎重
 */
fun layoutFileCompat() {
    var file = File("") // 根目录
    val filePath = file.absolutePath
    file = File(filePath)
    val modules = file.list()
    if (modules == null || modules.isEmpty()) {
        println("目录不存在")
        return
    }
    var temp: File
    var sb: StringBuilder
    val basePath = file.absolutePath
    for (module in modules) {
        sb = StringBuilder()
        sb.append(basePath).append(File.separator)
            .append(module).append(File.separator)
            .append("src").append(File.separator)
            .append("main").append(File.separator)
            .append("res").append(File.separator)
            .append("layout")
        val layoutPath = sb.toString()
        temp = File(layoutPath)
        if (!temp.exists()) {
            continue
        }
        val layoutFiles = temp.listFiles()
        for (layoutFile in layoutFiles) {
            if (layoutFile != null) {
                resetLayoutFileDimens(layoutFile, "UTF-8")
            }
        }
    }
}

fun resetLayoutFileDimens(file: File, encode: String?) {
    if (!file.exists() || file.isDirectory) {
        return
    }
    var bReader: BufferedReader? = null
    val fs: FileInputStream
    val ir: InputStreamReader
    val sb = StringBuilder()
    try {
        fs = FileInputStream(file)
        ir = InputStreamReader(fs, encode)
        bReader = BufferedReader(ir)
        var line = bReader.readLine()
        while (line != null) {
            sb.append(replace(line)).append("\n")
            line = bReader.readLine()
        }
    } catch (e: Exception) {
        e.printStackTrace()
    } finally {
        try {
            bReader!!.close()
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
    try {
        writeFile(file, sb.toString())
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

private fun replace(line: String): String {
    /**
     * 匹配:"数字.(0个或1个.)数字(0个或多个数字)dp"
     */
    val regex = "\"\\d+.?\\d*(dp\")"
    val p = Pattern.compile(regex)
    val m = p.matcher(line)
    var temp = line
    while (m.find()) {
        val dpValue = line.substring(m.start() + 1, m.end() - 3)
        temp = line.replace(
            line.substring(m.start() + 1, m.end() - 1), regDimen + dpValue
        )
        println(line)
        println(temp)
    }
    return temp
}
  1. 用到的两个方法
/**
 * 向一个文件里面写一段文本
 * @param file 输出的文件
 * @param content 要写入文件的文本内容
 * @param encode  传null,或者空,则使用默认UTF-8
 */
fun writeFile(file: File, content: String) {
    var writer: BufferedWriter? = null
    val write: OutputStreamWriter
    val fs: FileOutputStream
    try {
        val parent = file.parentFile
        if (parent != null && !parent.exists()) {
            parent.mkdirs()
        }
        if (!file.exists()) {
            file.createNewFile()
        }
        fs = FileOutputStream(file)
        write = OutputStreamWriter(fs, "UTF-8")
        writer = BufferedWriter(write)
        writer.write(content)
        writer.flush()
    } finally {
        close(writer)
    }
}

fun close(closeable: Closeable?) {
    try {
        closeable?.close()
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

/**
 * 将一个Document对象写入到xml文件
 * @param doc
 * @param filePath
 * @throws Exception
 */
@Throws(Exception::class)
fun domToXmlFile(doc: Document, filePath: String) {
    var pw: PrintWriter? = null
    try {
        val tf = TransformerFactory.newInstance()
        val transformer: Transformer
        transformer = tf.newTransformer()
        val source = DOMSource(doc)
        transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8")
        transformer.setOutputProperty(OutputKeys.INDENT, "yes")
        pw = PrintWriter(File(filePath))
        val streamResult = StreamResult(pw)
        transformer.transform(source, streamResult)
        println(filePath)
    } catch (e: Exception) {
        throw e
    } finally {
        close(pw)
    }
}

使用时需要注意:1.代码会在core这个module中生成/res/values-sw[xyz]dp目录,若没有core这个module,请修改代码;2.需要将自己项目的UI设计稿宽度替换DESIGN_WIDTH这个值;3.MAX_VALUES这里取了720,看需要,值越大,生成的文件就越大,apk体积也如此。

三、AutoDensity适配方案

github地址:AutoDensity

AutoDensity适配方案核心见这篇博客一种极低成本的Android屏幕适配方式,这是今日头条公布的一种适配方案。今日头条屏幕适配方案终极版正式发布,这篇文章在此基础上进行来封装和扩展。为了更好的理解和应用,本人也重复造了个轮子AutoDensity,在今日头条的思想上进行封装,尽可能简单,实现了一下内容:

  • 为特定Activity指定设计尺寸 ;
  • 支持Activity选择不使用适配 ;
  • 支持第三方SDK引入的Activity适配(选择适配或者保持原来) ;
  • 支持选择smallest-width或者height作为设计基准 ;
  • 支持选择字体是否跟随适配;
  • 支持不同pad尺寸选择策略适配。

一套UI交互适配phone和pad,很多时候不可能为pad再做一套布局和交互,工作量实在太大了。所以要么选择为pad定做一个app,要么就让phone版本的app直接跑在pad上,只是显示的UI要大一些,发现很多app通常选择后者。

AutoDensity如何接入?只需要在自定义的Application中增加一行代码即可:

AutoDensity.instance.init(this, DesignDraft(designSize = 375f))

四、最佳做法

在写AutoDensity之前,我觉得smallest-width已经足够好了,除了会增加一些apk体积,以及极少的代码入侵。但是,对于接入的第三方SDK,如果包含有Activity,它就有点无能为力了。当然,smallest-width一个优点就是稳定。两种方案,各有优缺点吧,同时使用两种方案是不可能的。但是可以在使用AutoDensity的同时,保留smallest-with,也就是前面说的,只生成一份1比1的dimens,放在common模块的values目录,然后在布局和代码中使用里面的dimen,同时使用AutoDensity适配所有屏幕。这样既使用了AutoDensity的方便,也保留了以后改用smallest-width的可能。相信在使用了AutoDensity后,都不愿意用其它方案了,毕竟这效率是摆在那里的。

五、参考

  • Android屏幕适配全攻略(最权威的官方适配指导)
  • 一种极低成本的Android屏幕适配方式
  • 今日头条屏幕适配方案终极版正式发布
  • AndroidAutoSize

你可能感兴趣的:(Kotlin,android,工具)