與動態(tài)設置字體大小實戰(zhàn))
1. 字體適配為什么成了Android開發(fā)里的“隱形地雷”做過Android應用的人大概都有過這種經歷在測試機上跑得好好的界面到了用戶手里就變成了“車禍現(xiàn)場”——要么文字大得溢出屏幕要么小得看不清要么中英文混排時行高突然塌陷。更讓人頭疼的是這類問題往往在開發(fā)階段很難復現(xiàn)因為大多數(shù)開發(fā)者用的都是默認字體縮放比例的測試機。字體適配這件事表面上看只是setTextSize()里傳個數(shù)值那么簡單但真正踩過坑的人都知道它牽扯到系統(tǒng)設置、資源配置、自定義View、第三方庫、多語言切換等一連串環(huán)節(jié)。尤其是當產品經理提出“我們的應用不要跟隨系統(tǒng)字體大小變化”或者“某些頁面要動態(tài)調整字號”這類需求時如果沒有提前搞清楚底層機制后面就會陷入反復改、反復錯的循環(huán)。這篇文章面向的是有一定Android開發(fā)基礎、正在被字體適配問題困擾的開發(fā)者。我會從系統(tǒng)字體縮放的底層邏輯講起把“不跟隨系統(tǒng)字體大小”和“動態(tài)設置字體大小”這兩個高頻需求拆開揉碎結合我自己在多個項目中踩過的坑給出可直接落地的方案。文章里涉及的所有代碼和配置都經過實際驗證你可以直接拿去用但更重要的是理解每一步背后的原因這樣遇到變體需求時才不會慌。2. 系統(tǒng)字體縮放到底改了什么從Configuration到TextView的完整鏈路2.1 fontScale的存儲與生效時機Android系統(tǒng)在“設置-顯示-字體大小”里提供的調節(jié)選項本質上修改的是Configuration對象中的fontScale字段。這個值默認是1.0用戶每調大一檔它就會按預設的倍數(shù)增長比如1.15、1.3、1.5等。系統(tǒng)把這個值持久化在Settings.System.FONT_SCALE里當用戶修改后會觸發(fā)全局的配置變更。關鍵點在于這個配置變更會重啟當前應用的Activity除非你聲明了android:configChangesfontScale。重啟之后系統(tǒng)會重新創(chuàng)建Resources對象而Resources在解析sp單位時就會用fontScale去乘以基準值。這就是為什么你用sp設置的字體會跟著系統(tǒng)變而用dp設置的不會——因為dp的換算只依賴density跟fontScale沒關系。理解這條鏈路非常重要因為它決定了你后續(xù)所有適配方案的邊界。你能控制的環(huán)節(jié)主要有三個一是讓Activity不重啟、自己處理配置變更二是攔截Resources的字體縮放三是在代碼里動態(tài)計算字號。這三條路各有適用場景也各有各的坑。2.2 sp與dp的本質區(qū)別以及為什么不能簡單粗暴全用dp很多人第一次遇到字體適配問題得到的建議就是“把sp全換成dp”。這個做法確實能讓字體不跟隨系統(tǒng)變化但它帶來的副作用同樣明顯用戶如果確實需要大字體比如視力不佳的老年用戶你的應用就完全無視了他們的系統(tǒng)偏好這在無障礙體驗上是不合格的。sp的設計初衷就是“scale-independent pixels”它存在的意義就是尊重用戶的字體大小選擇。所以正確的思路不是一刀切地棄用sp而是分場景決策哪些界面必須保持布局穩(wěn)定比如強交互的儀表盤、游戲HUD哪些界面應該尊重用戶偏好比如閱讀類正文。對于前者我們可以用dp或者自定義的字體縮放策略對于后者保留sp反而是更友好的做法。這里還有一個容易被忽略的細節(jié)TextView的setTextSize()方法默認接收的是sp單位。如果你傳入一個純數(shù)字比如setTextSize(16)它內部會調用setTextSize(COMPLEX_UNIT_SP, 16)也就是說它依然會受fontScale影響。想要用dp必須顯式寫成setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16)。這個細節(jié)我在早期項目中就栽過跟頭當時以為用代碼設置就能繞開系統(tǒng)縮放結果測試時發(fā)現(xiàn)根本沒生效。2.3 Configuration變更引發(fā)的連鎖反應當用戶調整系統(tǒng)字體大小后如果Activity沒有聲明處理fontScale變更系統(tǒng)會銷毀并重建Activity。這個過程會帶來幾個連鎖問題一是頁面狀態(tài)丟失如果沒做好onSaveInstanceState用戶填了一半的表單就沒了二是如果應用里有單例持有Context重建后可能引用到舊的Resources導致字體顯示不一致三是如果用了自定義View在構造函數(shù)里讀取的字體尺寸可能還是舊值。我在一個項目里就遇到過這種情況用戶調整字體后主界面正常刷新了但一個常駐通知里的字體還是舊的。排查后發(fā)現(xiàn)通知的RemoteViews是在Service里構建的而Service并沒有隨Activity重建它持有的Resources還是舊的。解決辦法是在Service里監(jiān)聽配置變化或者每次構建通知時重新獲取當前配置。3. 讓應用不跟隨系統(tǒng)字體大小的幾種方案與取舍3.1 覆寫attachBaseContext與applyOverrideConfiguration最直接也最常用的方案是在BaseActivity里覆寫attachBaseContext創(chuàng)建一個新的Configuration把fontScale強制設為1.0然后用這個配置去創(chuàng)建Context。代碼大致如下Override protected void attachBaseContext(Context newBase) { Configuration config new Configuration(newBase.getResources().getConfiguration()); config.fontScale 1.0f; Context context newBase.createConfigurationContext(config); super.attachBaseContext(context); }這段代碼的核心邏輯是在Activity的Context被附加之前替換掉它的Configuration。這樣后續(xù)所有通過這個Context獲取的Resources在解析sp時都會用1.0的fontScale字體自然就不跟隨系統(tǒng)了。但這里有幾個坑必須注意。第一createConfigurationContext是API 17才有的方法如果你的minSdk低于17需要做版本判斷低版本可以用反射修改Resources的Configuration。第二這個方法只對通過該Context獲取的資源生效如果你在Application里初始化了某些全局的Resources那些地方不受影響。第三如果你用了androidx.appcompat庫AppCompatActivity本身也覆寫了attachBaseContext你需要確保調用順序正確否則可能被覆蓋。3.2 在Application級別統(tǒng)一處理如果希望整個應用包括Service、BroadcastReceiver等組件都不跟隨系統(tǒng)字體只在Activity里處理是不夠的。更徹底的做法是在自定義Application里覆寫onConfigurationChanged或者在attachBaseContext里做全局替換。不過這里要特別小心Application的attachBaseContext只會被調用一次之后系統(tǒng)配置變化時不會再次觸發(fā)。所以如果你在Application里固定了fontScale用戶后續(xù)調整系統(tǒng)字體時應用內的字體不會實時變化——這恰好是“不跟隨”的需求但如果你希望部分頁面跟隨、部分不跟隨就不能在Application級別一刀切。我的建議是如果產品明確要求全局不跟隨就在Application里統(tǒng)一處理如果只是某些頁面不跟隨就在對應的BaseActivity里處理。不要混用否則容易出現(xiàn)“有的頁面跟、有的不跟”的混亂局面。3.3 自定義TextView與字體縮放攔截還有一種更細粒度的方案自定義一個TextView在setTextSize里做攔截或者重寫getTextSize的返回值。這種方案適合只需要對特定控件做處理的場景比如某個展示數(shù)字的儀表盤。但我不太推薦大規(guī)模使用這種方式原因有二一是侵入性強每個需要控制的控件都要替換成自定義View維護成本高二是容易遺漏比如Button、EditText這些間接繼承自TextView的控件如果忘記替換就會出現(xiàn)不一致。相比之下在Context層面做統(tǒng)一處理影響面更可控代碼也更集中。當然如果你的項目已經大量使用了自定義View那在自定義View的基類里統(tǒng)一處理也是可行的。3.4 方案對比與選型建議方案生效范圍實時性侵入性適用場景BaseActivity覆寫attachBaseContext單個Activity及其Fragment重建后生效低部分頁面不跟隨Application全局處理整個應用重建后生效低全局不跟隨自定義TextView攔截特定控件即時高個別控件特殊處理全用dp替代sp布局文件層面即時中布局穩(wěn)定性優(yōu)先選型時先問自己三個問題需求是全局還是局部是否需要尊重用戶的無障礙偏好項目里有沒有大量第三方庫的TextView需要統(tǒng)一處理想清楚這三點方案基本就定了。4. 動態(tài)設置字體大小的那些坑從setTextSize到全局刷新4.1 setTextSize的單位陷阱前面提過setTextSize(float size)默認用sp單位。但很多人不知道的是setTextSize(int unit, float size)里的unit參數(shù)如果傳TypedValue.COMPLEX_UNIT_PX那么size就是像素值不會再經過任何縮放。這個特性在需要精確控制像素尺寸的場景下很有用比如自定義繪制。我遇到過一個典型案例某個頁面需要根據屏幕寬度動態(tài)計算字號讓文字剛好占滿一行。如果用sp計算出來的值會被fontScale二次縮放導致實際顯示比預期大或小。正確的做法是用COMPLEX_UNIT_PX自己根據density和fontScale算出最終的像素值然后直接設置。float px TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, spValue, resources.getDisplayMetrics()); textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, px);這段代碼看起來繞但它把“sp轉px”的過程顯式化了你可以在中間插入自己的縮放邏輯比如乘以一個自定義的比例系數(shù)。4.2 動態(tài)調整后布局不刷新的問題動態(tài)設置字體大小后最常見的現(xiàn)象是文字變了但控件的大小沒變導致文字被截斷或者周圍留白過多。這是因為TextView的setTextSize只會觸發(fā)重繪不會自動重新測量布局。如果父布局用的是wrap_content通常沒問題但如果父布局是固定高度或者ConstraintLayout里用了固定尺寸約束就需要手動調用requestLayout()。更麻煩的是RecyclerView里的場景。你動態(tài)調整了某個Item的字體但Item的高度是固定的文字就會顯示不全。這時候要么在Adapter里根據字體大小重新計算Item高度要么用DiffUtil觸發(fā)局部刷新。我在一個聊天列表里就遇到過這個問題用戶調整字體后氣泡里的文字溢出了但氣泡背景沒跟著變。最后是在onBindViewHolder里根據當前字體大小重新設置氣泡的LayoutParams才解決。4.3 多語言切換與字體大小的疊加效應當應用支持多語言時字體適配會變得更復雜。不同語言的字符寬度差異很大比如同樣字號下中文和阿拉伯語的顯示寬度完全不同。如果再加上系統(tǒng)字體縮放計算量會成倍增加。我的經驗是對于多語言應用不要試圖用一套固定的字號適配所有語言。更合理的做法是在資源文件里為每種語言定義不同的dimen值然后在代碼里根據當前語言和字體縮放比例做二次調整。比如!-- values/dimens.xml -- dimen nametext_body16sp/dimen !-- values-ar/dimens.xml -- dimen nametext_body14sp/dimen這樣至少能保證基礎字號是合理的剩下的交給系統(tǒng)的fontScale去處理。如果產品要求不跟隨系統(tǒng)那就在Context層面統(tǒng)一攔截多語言的字號差異通過資源文件解決。4.4 第三方庫控件的字體適配項目里用了第三方UI庫的話字體適配的難度會再上一個臺階。因為第三方庫的TextView往往有自己的默認字號而且不一定暴露修改接口。比如某些彈窗庫、圖表庫它們的字號是寫死在代碼里的。對于這種情況我通常用兩種辦法一是通過主題Theme覆蓋在styles.xml里定義android:textSize的默認值很多庫會讀取主題屬性二是用LayoutInflater.Factory2在View創(chuàng)建時攔截動態(tài)替換字號。第二種方式比較重但對付那些頑固的庫很有效。getLayoutInflater().setFactory2(new LayoutInflater.Factory2() { Override public View onCreateView(View parent, String name, Context context, AttributeSet attrs) { View view getDelegate().createView(parent, name, context, attrs); if (view instanceof TextView) { // 在這里統(tǒng)一調整字號 } return view; } });這段代碼要慎用因為它會影響整個Activity的View創(chuàng)建過程稍有不慎就會引發(fā)性能問題或兼容性問題。建議只在確實沒有其他辦法時使用并且做好充分的測試。5. 一個可復用的字體適配工具類設計與實現(xiàn)5.1 工具類的核心接口設計經過多個項目的迭代我總結出一個相對通用的字體適配工具類。它的核心思路是把“是否跟隨系統(tǒng)”“縮放比例”“單位換算”這三個關注點分離對外提供簡潔的API。public class FontScaleHelper { private static float sAppFontScale 1.0f; private static boolean sFollowSystem true; public static void init(boolean followSystem, float appScale) { sFollowSystem followSystem; sAppFontScale appScale; } public static float getEffectiveScale(Resources res) { float systemScale sFollowSystem ? res.getConfiguration().fontScale : 1.0f; return systemScale * sAppFontScale; } public static float spToPx(float sp, Resources res) { return sp * res.getDisplayMetrics().scaledDensity * getEffectiveScale(res); } }這個設計的巧妙之處在于getEffectiveScale把系統(tǒng)縮放和應用自定義縮放合并成一個系數(shù)后續(xù)所有換算都基于這個系數(shù)。這樣無論產品需求怎么變只需要調整init里的參數(shù)即可。5.2 在BaseActivity中集成有了工具類BaseActivity的集成就很簡潔了Override protected void attachBaseContext(Context newBase) { Configuration config new Configuration(newBase.getResources().getConfiguration()); if (!FontScaleHelper.isFollowSystem()) { config.fontScale FontScaleHelper.getAppFontScale(); } Context context newBase.createConfigurationContext(config); super.attachBaseContext(context); }注意這里把fontScale設成了應用自定義的縮放值而不是固定1.0。這樣如果產品希望“不跟隨系統(tǒng)但應用內可以統(tǒng)一調大”也能支持。5.3 動態(tài)刷新已顯示界面的策略如果應用運行過程中需要動態(tài)改變字體大小比如設置頁里的“字體大小”選項就需要刷新已顯示的界面。最穩(wěn)妥的方式是重建Activityrecreate();但recreate()會丟失頁面狀態(tài)所以要在onSaveInstanceState里保存好必要的數(shù)據。如果不想重建也可以遍歷View樹手動更新字號但這種方式容易遺漏而且對RecyclerView里的Item處理起來很麻煩。我的建議是除非有強烈的性能要求否則優(yōu)先用recreate()。它雖然簡單粗暴但勝在可靠。配合ViewModel保存數(shù)據用戶體驗上不會有明顯問題。5.4 測試與驗證要點字體適配的測試不能只靠肉眼觀察要覆蓋以下幾種場景系統(tǒng)字體調到最小、默認、最大三檔分別檢查布局是否正常應用內動態(tài)調整字體后檢查所有頁面是否同步更新多語言切換后檢查文字是否溢出或截斷橫豎屏切換后檢查字體是否保持一致分屏模式下檢查字體是否跟隨窗口大小變化我通常會在開發(fā)者選項里打開“顯示布局邊界”這樣能直觀看到文字是否超出了控件范圍。另外Layout Inspector也是排查字體問題的好幫手它可以實時查看每個TextView的實際字號和測量尺寸。6. 那些年我踩過的字體適配坑與排查思路6.1 坑一WebView里的字體不跟隨應用設置WebView是個獨立的世界它的字體大小由CSS和WebSettings控制不受Android Context的fontScale影響。如果產品要求WebView里的文字也不跟隨系統(tǒng)需要在加載URL時注入CSS或者調用setTextZoom(100)。但setTextZoom有個坑它設置的是百分比100表示不縮放。如果系統(tǒng)字體很大WebView默認會跟隨系統(tǒng)縮放這時候setTextZoom(100)能把它拉回來。但如果網頁本身用了rem或em單位可能還需要額外處理。6.2 坑二Toast和Dialog的字體不受Activity控制Toast和Dialog如果用的是Application的Context那么它們不會受到BaseActivity里Configuration替換的影響。解決辦法是給它們傳入Activity的Context或者單獨為它們創(chuàng)建配置好的Context。Dialog的情況更復雜一些因為AlertDialog內部會用自己的Context去inflate布局。如果直接傳Activity的Context通常沒問題但如果用了DialogFragment它默認用的是Fragment的Context需要確保Fragment所在的Activity已經處理過字體配置。6.3 坑三自定義View在構造函數(shù)里讀取字體尺寸自定義View如果在構造函數(shù)里通過TypedArray讀取了textSize那么這個值是在View創(chuàng)建時確定的。如果之后系統(tǒng)字體變了View重建了但構造函數(shù)里讀到的還是舊值就會不一致。解決辦法是在onConfigurationChanged里重新讀取或者干脆不在構造函數(shù)里讀而是在onDraw或onMeasure時動態(tài)獲取。后者性能稍差但一致性更好。6.4 坑四RecyclerView的ItemDecoration與字體聯(lián)動ItemDecoration里的間距通常是固定值但如果字體變大Item高度變了間距看起來就不協(xié)調了。這時候需要在getItemOffsets里根據當前字體縮放比例動態(tài)計算間距。這個坑比較隱蔽因為間距不對不會導致崩潰只是視覺上不舒服。我是在一次用戶反饋里才注意到的用戶說“調大字體后列表項之間擠在一起了”。排查后發(fā)現(xiàn)是ItemDecoration的間距沒跟著變。6.5 排查鏈路與工具遇到字體問題時我通常按這個順序排查確認問題范圍是單個頁面還是全局是所有文字還是特定控件檢查Context來源出問題的控件用的是哪個Context是否經過了字體配置處理檢查單位用的是sp還是dpsetTextSize傳的是什么單位檢查布局父布局是否限制了高度是否用了wrap_content檢查第三方庫是否有庫內部寫死了字號或用了自己的Context工具方面除了前面提到的Layout Inspectoradb shell dumpsys activity top也能看到當前Activity的Configuration信息包括fontScale的值。這個命令在排查“為什么這個頁面跟其他頁面不一樣”時特別有用。7. 關于字體適配我的幾點個人體會字體適配這件事技術難度不算高但瑣碎程度很高。它不像性能優(yōu)化那樣有明確的指標也不像架構設計那樣有清晰的邊界它更像是“細節(jié)的堆砌”——每一個小問題單獨看都不難但湊在一起就容易讓人煩躁。我的第一條體會是盡早統(tǒng)一方案。項目初期就確定好字體適配策略是跟隨系統(tǒng)還是不跟隨是全局處理還是局部處理然后寫進開發(fā)規(guī)范里。不要等到頁面做了一大半才想起來處理那時候改造成本會高很多。第二條體會是不要迷信“最佳實踐”。網上很多文章說“全用dp”或者“全用sp”但實際項目里往往是混合使用的。關鍵是要理解每種方案的適用場景和副作用然后根據產品需求做取舍。第三條體會是測試要覆蓋極端情況。系統(tǒng)字體調到最大、最小多語言切換分屏模式這些場景在開發(fā)階段很容易被忽略但恰恰是問題最多的地方。我現(xiàn)在的習慣是每個版本提測前都會專門花半小時把這些場景過一遍。最后分享一個小技巧如果你不確定某個控件的字體是從哪里來的可以在TextView的setTextSize方法上下斷點然后看調用棧。這個方法雖然笨但非常有效尤其是排查第三方庫的字體問題時能快速定位到是哪一層設置的。