目錄
前言
一個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對應值,不過請在瞭解它和自身的需求後慎重使用。
- 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)
}
- 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
}
- 用到的兩個方法
/**
* 向一個文件裏面寫一段文本
* @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後,都不願意用其它方案了,畢竟這效率是擺在那裏的。