刻微信界面:Fragment+RecyclerView實(shí)戰(zhàn)與避坑指南)
簡(jiǎn)介這份資源面向具備一定 Java 基礎(chǔ)、希望借助 Android Studio 實(shí)踐界面開(kāi)發(fā)的初學(xué)者與進(jìn)階學(xué)習(xí)者圍繞仿微信客戶端界面這一典型場(chǎng)景幫助讀者打通從布局搭建到交互細(xì)節(jié)的完整鏈路。壓縮包共 1041 個(gè)文件約 24.25MB以 flat、json、xml 等構(gòu)建與布局配置為主輔以 png、jpg 圖片素材、java 源碼、dex 與 class 編譯產(chǎn)物及 gradle 構(gòu)建腳本完整保留了可運(yùn)行的工程結(jié)構(gòu)。內(nèi)容覆蓋 ConstraintLayout 布局、BottomNavigationView 底部導(dǎo)航、RecyclerView 聊天列表與適配器數(shù)據(jù)綁定、聊天氣泡自定義 View、Glide 頭像加載、VectorDrawable 圖標(biāo)、主題配色與頁(yè)面切換動(dòng)畫(huà)等知識(shí)點(diǎn)并附有版本控制與更新記錄痕跡。已有 3103 人學(xué)習(xí)適合對(duì)照工程源碼理解仿微信界面的組織方式與實(shí)現(xiàn)思路。1. 從一張聊天列表說(shuō)起為什么用 Android Studio 復(fù)刻微信界面值得認(rèn)真做很多同學(xué)接到「用 Android Studio 制作微信的界面」這個(gè)題目時(shí)第一反應(yīng)是打開(kāi)布局文件拖控件把頭像、昵稱(chēng)、消息氣泡擺得像模像樣就交差。但真正做過(guò)完整項(xiàng)目的人都知道界面復(fù)刻的難點(diǎn)從來(lái)不在「像不像」而在「撐不撐得住」——消息列表滾動(dòng)到第 200 條會(huì)不會(huì)卡、底部導(dǎo)航切換時(shí) Fragment 會(huì)不會(huì)重疊、軟鍵盤(pán)彈出后輸入框會(huì)不會(huì)被頂飛。這些才是答辯時(shí)老師最?lèi)?ài)追問(wèn)、面試官最?lèi)?ài)深挖的地方。這套方案面向兩類(lèi)人一是正在做 Android 課程設(shè)計(jì)或畢設(shè)、需要交一份結(jié)構(gòu)完整可運(yùn)行作品的學(xué)生二是想通過(guò)一個(gè)高辨識(shí)度案例把 Activity、Fragment、RecyclerView、自定義 View 串起來(lái)的入門(mén)開(kāi)發(fā)者。微信界面恰好覆蓋了移動(dòng)端最典型的幾類(lèi) UI 模式——列表流、底部導(dǎo)航、聊天詳情、朋友圈九宮格——把它拆透等于把 Android UI 開(kāi)發(fā)的主干走了一遍。下面按需求、架構(gòu)、模塊實(shí)現(xiàn)、避坑、驗(yàn)收的順序展開(kāi)代碼和結(jié)構(gòu)都可以直接照著改。2. 需求拆解與技術(shù)選型四個(gè)頁(yè)面、三層結(jié)構(gòu)怎么定2.1 功能邊界先劃清「做什么」和「不做什么」復(fù)刻微信界面最容易翻車(chē)的地方是范圍失控。微信本身功能龐雜如果一開(kāi)始就想著把支付、小程序、視頻號(hào)都塞進(jìn)去最后大概率是每個(gè)頁(yè)面都半成品。我一般會(huì)先把范圍壓到「四個(gè)主 Tab 一個(gè)聊天詳情頁(yè)」這個(gè)最小可交付集合模塊核心功能交付標(biāo)準(zhǔn)微信聊天列表會(huì)話列表、未讀紅點(diǎn)、置頂列表可滾動(dòng)、點(diǎn)擊進(jìn)入詳情通訊錄字母索引、分組側(cè)邊字母條可快速定位發(fā)現(xiàn)朋友圈入口、功能宮格宮格點(diǎn)擊有反饋我個(gè)人信息卡、設(shè)置項(xiàng)列表頭像昵稱(chēng)可展示聊天詳情消息氣泡、輸入框、發(fā)送發(fā)送后消息上屏并滾動(dòng)到底明確「不做什么」同樣重要不做真實(shí)網(wǎng)絡(luò)請(qǐng)求、不做賬號(hào)體系、不做消息推送。數(shù)據(jù)全部用本地模擬這樣既保證可運(yùn)行又不會(huì)因?yàn)楹蠖巳笔?dǎo)致答辯時(shí)頁(yè)面空白。這個(gè)邊界劃清楚后面所有設(shè)計(jì)都有了錨點(diǎn)。2.2 技術(shù)選型為什么是 Fragment RecyclerView 這套組合界面框架的選擇直接決定了代碼的可維護(hù)性。底部四個(gè) Tab 的切換常見(jiàn)有三種做法多個(gè) Activity 跳轉(zhuǎn)、ViewPager 滑動(dòng)、Fragment 容器替換。第一種會(huì)導(dǎo)致返回?;靵y第二種在微信這種「Tab 之間不該滑動(dòng)切換」的場(chǎng)景里體驗(yàn)不對(duì)所以標(biāo)準(zhǔn)做法是單 Activity 四個(gè) Fragment 底部自定義導(dǎo)航欄。列表部分必須用 RecyclerView 而不是 ListView原因很實(shí)在ViewHolder 復(fù)用機(jī)制成熟、支持局部刷新、能配合 ItemDecoration 做分割線。聊天列表、通訊錄、消息氣泡三處列表全部復(fù)用同一套 RecyclerView 思路只是 Adapter 的 item 布局不同。數(shù)據(jù)層用一個(gè)簡(jiǎn)單的本地 Repository 單例持有模擬數(shù)據(jù)避免把數(shù)據(jù)散落在各個(gè) Fragment 里。依賴(lài)方面保持克制除了 AndroidX 基礎(chǔ)庫(kù)和 Material 組件不引入第三方 UI 框架。原因很簡(jiǎn)單復(fù)刻微信界面的價(jià)值就在于自己寫(xiě)布局、自己調(diào)間距套一個(gè)現(xiàn)成框架反而失去了練習(xí)意義答辯時(shí)也講不出東西。2.3 工程目錄按功能分包而不是按類(lèi)型分包新手常犯的錯(cuò)是把所有 Activity 放一個(gè)包、所有 Adapter 放一個(gè)包項(xiàng)目一大就找不到東西。我一般按功能模塊分包每個(gè)模塊內(nèi)部再分頁(yè)面和數(shù)據(jù)com.example.wechatui ├── main/ # 主容器 │ ├── MainActivity.java │ └── BottomNavView.java # 自定義底部導(dǎo)航 ├── chat/ # 微信 Tab │ ├── ChatListFragment.java │ ├── ChatListAdapter.java │ └── ChatDetailActivity.java ├── contact/ # 通訊錄 Tab │ ├── ContactFragment.java │ └── ContactAdapter.java ├── discover/ # 發(fā)現(xiàn) Tab ├── profile/ # 我 Tab ├── data/ # 模擬數(shù)據(jù)與 Repository │ ├── ChatRepository.java │ └── model/ # 實(shí)體類(lèi) └── widget/ # 通用自定義 View ├── AvatarView.java └── BadgeView.java # 未讀紅點(diǎn)這樣分包的好處是改聊天列表只動(dòng) chat 包通用控件沉到 widget 包數(shù)據(jù)統(tǒng)一走 data 包。答辯時(shí)老師問(wèn)「你的項(xiàng)目結(jié)構(gòu)怎么組織的」這套目錄本身就是答案。3. 主框架與導(dǎo)航實(shí)現(xiàn)單 Activity 容器怎么搭穩(wěn)3.1 底部導(dǎo)航欄自定義 View 比 BottomNavigationView 更可控系統(tǒng)自帶的 BottomNavigationView 能用但微信底部導(dǎo)航有幾個(gè)細(xì)節(jié)它不好處理選中態(tài)圖標(biāo)變色、未讀紅點(diǎn)疊加、點(diǎn)擊時(shí)圖標(biāo)有輕微縮放。所以我一般自己寫(xiě)一個(gè)BottomNavView內(nèi)部用 LinearLayout 橫排四個(gè) item每個(gè) item 是一個(gè)圖標(biāo)加文字的垂直組合。public class BottomNavView extends LinearLayout { private int currentIndex 0; private OnTabSelectedListener listener; public BottomNavView(Context context, AttributeSet attrs) { super(context, attrs); setOrientation(HORIZONTAL); // 四個(gè) Tab 的圖標(biāo)與文字資源 int[] icons {R.drawable.ic_chat, R.drawable.ic_contact, R.drawable.ic_discover, R.drawable.ic_me}; String[] titles {微信, 通訊錄, 發(fā)現(xiàn), 我}; for (int i 0; i icons.length; i) { View item inflate(context, R.layout.item_bottom_nav, this); ImageView icon item.findViewById(R.id.nav_icon); TextView title item.findViewById(R.id.nav_title); icon.setImageResource(icons[i]); title.setText(titles[i]); final int index i; item.setOnClickListener(v - selectTab(index)); } } public void selectTab(int index) { if (index currentIndex) return; currentIndex index; // 遍歷刷新選中態(tài)選中變綠未選中變灰 for (int i 0; i getChildCount(); i) { View child getChildAt(i); boolean selected (i index); ((ImageView) child.findViewById(R.id.nav_icon)) .setColorFilter(selected ? 0xFF07C160 : 0xFF999999); ((TextView) child.findViewById(R.id.nav_title)) .setTextColor(selected ? 0xFF07C160 : 0xFF999999); } if (listener ! null) listener.onTabSelected(index); } }這段代碼的關(guān)鍵點(diǎn)有三個(gè)一是selectTab里先判斷index currentIndex直接返回避免重復(fù)點(diǎn)擊觸發(fā)無(wú)謂的 Fragment 重建二是選中色用微信標(biāo)志性的綠色0xFF07C160未選中用灰色顏色值集中在這里方便統(tǒng)一調(diào)整三是通過(guò)OnTabSelectedListener把切換事件拋給 MainActivity導(dǎo)航欄本身不持有 Fragment 邏輯職責(zé)單一。參數(shù)上圖標(biāo)資源建議用矢量圖 VectorDrawable這樣一套圖能適配所有屏幕密度不用準(zhǔn)備多套 png。3.2 Fragment 容器用 show/hide 而不是 replaceMainActivity 里承載四個(gè) Fragment切換方式有兩種replace和show/hide。replace每次都會(huì)銷(xiāo)毀重建 Fragment聊天列表滾動(dòng)位置會(huì)丟失體驗(yàn)很差。正確做法是初始化時(shí)一次性 add 四個(gè) Fragment之后只做 show/hideprivate void switchFragment(int index) { FragmentTransaction tx getSupportFragmentManager().beginTransaction(); for (int i 0; i fragments.size(); i) { Fragment f fragments.get(i); if (i index) { tx.show(f); } else { tx.hide(f); } } tx.commit(); }這里有個(gè)血淚經(jīng)驗(yàn)四個(gè) Fragment 在onCreate里一次性 add 時(shí)一定要給每個(gè) Fragment 設(shè)置 tag 或者用集合持有引用否則配置變更比如旋轉(zhuǎn)屏幕后getSupportFragmentManager恢復(fù)的 Fragment 和你手里的引用對(duì)不上就會(huì)出現(xiàn)「切到通訊錄卻顯示聊天列表」的玄學(xué)問(wèn)題。穩(wěn)妥的做法是在onCreate里先判斷savedInstanceState null為空才執(zhí)行初始化 add不為空則從 FragmentManager 里按 tag 找回。3.3 狀態(tài)保存旋轉(zhuǎn)屏幕不丟數(shù)據(jù)的兜底界面類(lèi)項(xiàng)目最容易被忽略的是配置變更。用戶在聊天詳情頁(yè)輸入了一半文字轉(zhuǎn)個(gè)屏內(nèi)容沒(méi)了這種 bug 答辯時(shí)被當(dāng)場(chǎng)演示就很尷尬。處理方式分兩層輸入框內(nèi)容用onSaveInstanceState存到 Bundle列表滾動(dòng)位置 RecyclerView 自帶LinearLayoutManager的狀態(tài)保存只要 Adapter 數(shù)據(jù)源沒(méi)被清空就能自動(dòng)恢復(fù)。對(duì)于「我」頁(yè)面這種靜態(tài)展示不需要額外處理。這套兜底做完才算一個(gè)能經(jīng)得起折騰的界面。4. 核心頁(yè)面實(shí)現(xiàn)聊天列表與消息氣泡怎么落地4.1 聊天列表多類(lèi)型 Item 與未讀紅點(diǎn)微信聊天列表的 item 結(jié)構(gòu)是固定的左側(cè)頭像、中間昵稱(chēng)加最后一條消息、右側(cè)時(shí)間加未讀紅點(diǎn)。用 RecyclerView 實(shí)現(xiàn)時(shí)Adapter 的onBindViewHolder負(fù)責(zé)把數(shù)據(jù)填進(jìn)去紅點(diǎn)用自定義的BadgeView控制顯隱Override public void onBindViewHolder(ViewHolder holder, int position) { ChatItem item dataList.get(position); holder.name.setText(item.getName()); holder.lastMsg.setText(item.getLastMessage()); holder.time.setText(item.getTime()); // 未讀數(shù)大于 0 才顯示紅點(diǎn)超過(guò) 99 顯示 99 if (item.getUnread() 0) { holder.badge.setVisibility(View.VISIBLE); holder.badge.setText(item.getUnread() 99 ? 99 : String.valueOf(item.getUnread())); } else { holder.badge.setVisibility(View.GONE); } // 頭像用本地資源實(shí)際項(xiàng)目可換 Glide 加載 holder.avatar.setImageResource(item.getAvatarRes()); }設(shè)計(jì)說(shuō)明BadgeView繼承 TextView在onDraw里畫(huà)一個(gè)紅色圓角背景文字居中。未讀數(shù)超過(guò) 99 顯示「99」是微信的真實(shí)規(guī)則這個(gè)細(xì)節(jié)寫(xiě)進(jìn)去答辯時(shí)很加分。頭像這里用本地資源是為了保證離線可運(yùn)行如果后續(xù)要接網(wǎng)絡(luò)圖片把setImageResource換成圖片加載庫(kù)即可接口不變。列表分割線用ItemDecoration實(shí)現(xiàn)左邊留出頭像寬度這樣分割線不會(huì)頂?shù)狡聊贿吘壓臀⑿诺囊曈X(jué)效果一致。4.2 消息氣泡用 9-patch 還是自定義 Drawable聊天詳情頁(yè)的消息氣泡是整套界面里最考驗(yàn)細(xì)節(jié)的部分。氣泡要有小三角指向頭像方向左右兩側(cè)消息三角方向相反。實(shí)現(xiàn)方式有兩種9-patch 圖片和自定義 Drawable。9-patch 簡(jiǎn)單但需要準(zhǔn)備兩套圖自定義 Drawable 靈活但代碼稍多。我一般選后者用LayerDrawable組合一個(gè)圓角矩形加一個(gè)旋轉(zhuǎn) 45 度的正方形來(lái)模擬三角!-- res/drawable/bubble_left.xml 左側(cè)接收氣泡 -- layer-list item shape android:shaperectangle solid android:color#FFFFFF/ corners android:radius8dp/ /shape /item item android:left-6dp android:top12dp android:bottom12dp rotate android:fromDegrees45 android:pivotX50% android:pivotY50% shape android:shaperectangle solid android:color#FFFFFF/ size android:width12dp android:height12dp/ /shape /rotate /item /layer-list邏輯說(shuō)明底層是圓角矩形做氣泡主體上層是一個(gè)旋轉(zhuǎn) 45 度的正方形通過(guò)負(fù)的 left 偏移讓它露出一半形成三角。右側(cè)氣泡把a(bǔ)ndroid:left改成android:right-6dp即可鏡像。參數(shù)上圓角 8dp、三角邊長(zhǎng) 12dp 是視覺(jué)上比較接近微信的比例實(shí)際調(diào)的時(shí)候以真機(jī)預(yù)覽為準(zhǔn)。消息列表同樣用 RecyclerView發(fā)送消息后調(diào)用notifyItemInserted并scrollToPosition到底部這兩步缺一不可只刷新不滾動(dòng)用戶會(huì)以為沒(méi)發(fā)出去。4.3 輸入框與軟鍵盤(pán)adjustResize 的正確用法聊天輸入框被軟鍵盤(pán)遮擋是高頻問(wèn)題。解決方案是在 AndroidManifest 里給 ChatDetailActivity 設(shè)置android:windowSoftInputModeadjustResize同時(shí)根布局不要用固定高度讓內(nèi)容區(qū)隨鍵盤(pán)壓縮。如果發(fā)現(xiàn)設(shè)置后無(wú)效八成是 Activity 用了全屏主題或者根布局是 ScrollView 包裹導(dǎo)致測(cè)量異常。穩(wěn)妥做法是根布局用 ConstraintLayout消息列表layout_height0dp加layout_weight撐滿剩余空間輸入框貼底這樣鍵盤(pán)彈出時(shí)列表自動(dòng)縮短輸入框始終可見(jiàn)。5. 避坑與排查五個(gè)最容易翻車(chē)的點(diǎn)5.1 現(xiàn)象切換 Tab 后 Fragment 重疊原因通常是replace和add混用或者初始化時(shí)重復(fù) add 了同一個(gè) Fragment。解決方式是統(tǒng)一用 show/hide并在onCreate里判斷savedInstanceState是否為空為空才初始化不為空從 FragmentManager 按 tag 找回已有實(shí)例。5.2 現(xiàn)象列表滑動(dòng)卡頓、圖片錯(cuò)位原因多半是在onBindViewHolder里做了耗時(shí)操作或者圖片加載沒(méi)有復(fù)用機(jī)制導(dǎo)致錯(cuò)位。解決方式是把數(shù)據(jù)綁定邏輯控制在輕量級(jí)圖片加載統(tǒng)一走緩存庫(kù)并在綁定前先重置占位圖避免復(fù)用時(shí)顯示上一條的數(shù)據(jù)。5.3 現(xiàn)象未讀紅點(diǎn)位置偏移原因是 BadgeView 的父布局約束不對(duì)紅點(diǎn)用了絕對(duì)定位。解決方式是把紅點(diǎn)放在頭像的 FrameLayout 里用layout_gravitytop|end加負(fù) margin 微調(diào)這樣無(wú)論頭像尺寸怎么變紅點(diǎn)始終貼著右上角。5.4 現(xiàn)象軟鍵盤(pán)彈出后輸入框消失原因是windowSoftInputMode沒(méi)設(shè)或設(shè)成了adjustPan。解決方式是改成adjustResize并檢查根布局是否有固定高度或全屏屬性。如果還不行在代碼里監(jiān)聽(tīng)布局變化手動(dòng)滾動(dòng)列表到底部。5.5 現(xiàn)象真機(jī)上字體和間距與設(shè)計(jì)稿對(duì)不上原因是設(shè)計(jì)稿用的 px 而布局用的 dp或者沒(méi)考慮狀態(tài)欄高度。解決方式是統(tǒng)一用 dp 和 sp狀態(tài)欄高度通過(guò)WindowInsets動(dòng)態(tài)獲取不要寫(xiě)死 24dp。這個(gè)坑幾乎每個(gè)新手都會(huì)踩一次提前統(tǒng)一單位能省很多返工。6. 驗(yàn)收與答辯怎么證明這套界面「真的能用」驗(yàn)收這套界面我一般分三步走每一步都有可量化的檢查點(diǎn)。第一步是功能走查四個(gè) Tab 逐個(gè)點(diǎn)擊確認(rèn)切換無(wú)重疊、選中態(tài)正確聊天列表滾動(dòng)到第 100 條以上觀察是否掉幀進(jìn)入聊天詳情發(fā)送三條消息確認(rèn)上屏順序和滾動(dòng)位置正確旋轉(zhuǎn)屏幕確認(rèn)輸入框內(nèi)容和列表位置不丟。第二步是邊界測(cè)試未讀數(shù)設(shè)為 0、99、100 三個(gè)值確認(rèn)紅點(diǎn)顯隱和「99」邏輯通訊錄字母索引滑到首尾確認(rèn)不越界軟鍵盤(pán)彈出收起反復(fù)五次確認(rèn)輸入框不漂移。第三步是代碼審查檢查是否有硬編碼顏色和尺寸、Fragment 是否有內(nèi)存泄漏風(fēng)險(xiǎn)、Adapter 是否用了 ViewHolder。答辯時(shí)老師最?lèi)?ài)問(wèn)的三個(gè)問(wèn)題提前準(zhǔn)備好答案一是「為什么用 Fragment 不用 Activity」答返回棧管理和狀態(tài)保持二是「RecyclerView 比 ListView 好在哪」答 ViewHolder 強(qiáng)制復(fù)用和局部刷新三是「你的項(xiàng)目怎么保證可擴(kuò)展」答按功能分包加 Repository 統(tǒng)一數(shù)據(jù)源。演示環(huán)節(jié)建議準(zhǔn)備一份「異常演示腳本」主動(dòng)展示旋轉(zhuǎn)屏幕、快速切換 Tab、長(zhǎng)列表滾動(dòng)這幾個(gè)場(chǎng)景比被動(dòng)等老師挑刺從容得多。從那以后我每次做界面類(lèi)項(xiàng)目都會(huì)在提交前強(qiáng)制走一遍「旋轉(zhuǎn)屏幕 快速切換 長(zhǎng)列表」這三連測(cè)因?yàn)榉?chē)往往就藏在這些最不起眼的操作里。希望這套結(jié)構(gòu)和實(shí)現(xiàn)思路能幫到你把這份界面作業(yè)做成一份拿得出手的作品。本文還有配套的精品資源點(diǎn)擊獲取