這篇文章將概述 Android組件化的架構搭建 及 Flutter 和 Android 如何混合開發 (整個App只有首頁是用原生Android完成,其他頁面都是引入之前的做好的Flutter頁面) ,主宿主程序由 Android 搭建,採用了組件化的架構搭建整個 App ,不同業務,對應不同的 module 工程,業務之間採用接口通信 (ARouter),以 module 的形式混入 Flutter,通過 MethodChannel 和 Flutter 端進行數據通信等,且這些功能實現源碼開源,感興趣的小夥伴可以移步至 GitHub。
以下博文會分爲4個部分概述:
- 項目完成的功能預覽
- 項目組件化結構分析
- 項目功能詳細概述(所用知識點)
- Android Flutter 混合開發
項目組件化結構分析
項目結構圖預覽
其次,分析梳理下項目結構,項目的結構大致如圖,還有一些細枝末節的沒有體現在圖裏:
項目結構分析
業務工程
把具體獨立的業務都拆分成單獨的 module 減小項目的維護壓力
- ft_home: 首頁模塊,這個模塊其實還可以繼續拆分,可把4個 tab (精選、附近、景點、美食) 頁都拆成模塊,這裏我暫時沒有拆分,後續會完成
- ft_destination: 目的地模塊,其實並沒有建立這個模塊,因爲直接引入了之前做好的 flutter 頁面
- ft_travel: 旅拍模塊,同樣也使用了 flutter 頁面
- flutter: flutter模塊,這個模塊是從 flutter_module 中自動生成的,後面介紹到
基礎庫工程
把具體的功能都封裝成獨立的庫供業務模塊使用,降低項目的維護成本及代碼之間耦合性
- lib_network: 網絡庫,使用 okhttp 插件二次封裝,業務層簡單的調用即可
- lib_webview: 打開網頁的webview庫,使用了 agentweb 插件二次封裝,業務層只需要一句代碼即可完成網頁的跳轉
- lib_image_loader: 圖片加載庫,使用了 glide 插件二次封裝,業務層只需一句代碼即可加載不同參數的圖片
- lib_asr: 百度AI語音庫,通過 Android 集成好供 Flutter 端使用
- lib_common_ui: 公共UI庫,重複多次使用的頁面集中管理
- lib_base: 基礎庫,通過 ARouter 的 service 功能暴露接口提供服務給業務層,當然業務層也可以在這裏暴露接口供外界使用
這裏有一些使用的插件並沒有在項目結構圖裏體現出來(結構圖空間有限)。
插件
在這裏把項目使用的插件整理列舉出來供大家參考:
- magicindicator 強大、可定製、易擴展的 ViewPager 指示器框架,首頁的4個 tab (精選、附近、景點、美食) 就是用這個實現的。
- immersionbar 一句代碼輕鬆實現狀態欄、導航欄沉浸式管理
- pagerBottomTabStrip 頁面底部和側邊的導航欄,首頁、目的地、旅拍、我的頁面切換就是用這個實現的。
- rxjava/rxandroid 異步和鏈式編程
- butterknife view注入插件,配合Android插件使用,可快速自動生成 init view的代碼,不用寫一句
findViewById
的代碼。 - gson json解析,配合Android插件使用,可快速生成實體類
- smartRefreshLayout 智能下拉刷新框架,攜程二樓及下拉刷新加載更多就是用這個實現的
- eventbus 發佈/訂閱事件總線,優雅的完成組件之間通信
- arouter 依賴注入、路由跳轉、註冊service,優雅的完成模塊之間的通信
- okhttp 網絡請求插件
- agentweb webview框架,進行簡單的二次封裝可優雅的進行網頁跳轉
- glide 高性能、可擴展的圖片加載插件
- banner 圖片輪播控件
基本就是這些了,應該沒有漏的,插件的詳細使用,請進入各插件的 GitHub 主頁。
在此,把我項目的插件引入代碼及版本管理的 gradle
代碼貼出來,如下:
插件引入代碼:
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation rootProject.depsLibs.appcompat
implementation rootProject.depsLibs.legacy
implementation rootProject.depsLibs.recyclerview
implementation rootProject.depsLibs.constraintlayout
implementation rootProject.depsLibs.cardview
//tab指示器
implementation rootProject.depsLibs.magicindicator
//沉浸式
implementation rootProject.depsLibs.immersionbar
//導航欄
implementation rootProject.depsLibs.pagerBottomTabStrip
//rxjava
implementation rootProject.depsLibs.rxjava
//rxandroid
implementation rootProject.depsLibs.rxandroid
//view 注入
implementation rootProject.depsLibs.butterknife
//view 注入
annotationProcessor rootProject.depsLibs.butterknifeCompiler
//gson
implementation rootProject.depsLibs.gson
//banner
implementation rootProject.depsLibs.banner
//smartRefreshLayout 上下拉刷新
implementation rootProject.depsLibs.smartRefreshLayout
implementation rootProject.depsLibs.refreshHeader
implementation rootProject.depsLibs.refreshHeaderTwoLevel
implementation rootProject.depsLibs.refreshFooter
//eventbus
implementation rootProject.depsLibs.eventbus
//arouter庫
implementation(rootProject.depsLibs.arouterapi) {
exclude group: 'com.android.support'
}
annotationProcessor rootProject.depsLibs.aroutercompiler
//引入home模塊
implementation project(':ft_home')
//引入圖片加載庫
implementation project(':lib_image_loader')
//引入網絡庫
implementation project(':lib_network')
//webview
implementation project(':lib_webview')
//引入基礎ui庫
implementation project(':lib_common_ui')
//base庫
implementation project(':lib_base')
//引入flutter模塊
implementation project(':flutter')
//引入百度AI語音庫
implementation project(':lib_asr')
}
版本管理代碼 (統一管理版本號) :
ext {
android = [
compileSdkVersion: 29,
buildToolsVersion: "29.0.0",
minSdkVersion : 19,
targetSdkVersion : 29,
applicationId : 'net.lishaoy.android_ctrip',
versionCode : 1,
versionName : '1.0',
multiDexEnabled : true,
]
depsVersion = [
appcompat : '1.1.0',
legacy : '1.0.0',
recyclerview : '1.0.0',
constraintlayout : '1.1.3',
cardview : '1.0.0',
magicindicator : '1.5.0',
immersionbar : '3.0.0',
pagerBottomTabStrip : '2.3.0X',
glide : '4.11.0',
glidecompiler : '4.11.0',
butterknife : '10.2.1',
butterknifeCompiler : '10.2.1',
rxjava : '3.0.0',
rxandroid : '3.0.0',
okhttp : '4.7.2',
okhttpLogging : '4.7.2',
gson : '2.8.6',
banner : '2.0.10',
smartRefreshLayout : '2.0.1',
refreshHeader : '2.0.1',
refreshFooter : '2.0.1',
refreshHeaderTwoLevel: '2.0.1',
eventbus : '3.2.0',
agentweb : '4.1.3',
arouterapi : '1.5.0',
aroutercompiler : '1.2.2',
]
depsLibs = [
appcompat : "androidx.appcompat:appcompat:${depsVersion.appcompat}",
legacy : "androidx.legacy:legacy-support-v4:${depsVersion.legacy}",
recyclerview : "androidx.recyclerview:recyclerview:${depsVersion.recyclerview}",
constraintlayout : "androidx.constraintlayout:constraintlayout:${depsVersion.constraintlayout}",
cardview : "androidx.cardview:cardview:${depsVersion.cardview}",
magicindicator : "com.github.hackware1993:MagicIndicator:${depsVersion.magicindicator}",
immersionbar : "com.gyf.immersionbar:immersionbar:${depsVersion.immersionbar}",
pagerBottomTabStrip : "me.majiajie:pager-bottom-tab-strip:${depsVersion.pagerBottomTabStrip}",
glide : "com.github.bumptech.glide:glide:${depsVersion.glide}",
glidecompiler : "com.github.bumptech.glide:compiler:${depsVersion.glidecompiler}",
butterknife : "com.jakewharton:butterknife:${depsVersion.butterknife}",
butterknifeCompiler : "com.jakewharton:butterknife-compiler:${depsVersion.butterknifeCompiler}",
rxjava : "io.reactivex.rxjava3:rxjava:${depsVersion.rxjava}",
rxandroid : "io.reactivex.rxjava3:rxandroid:${depsVersion.rxandroid}",
okhttp : "com.squareup.okhttp3:okhttp:${depsVersion.okhttp}",
okhttpLogging : "com.squareup.okhttp3:logging-interceptor:${depsVersion.okhttpLogging}",
gson : "com.google.code.gson:gson:${depsVersion.gson}",
banner : "com.youth.banner:banner:${depsVersion.banner}",
smartRefreshLayout : "com.scwang.smart:refresh-layout-kernel:${depsVersion.smartRefreshLayout}",
refreshHeader : "com.scwang.smart:refresh-header-classics:${depsVersion.refreshHeader}",
refreshHeaderTwoLevel: "com.scwang.smart:refresh-header-two-level:${depsVersion.refreshHeader}",
refreshFooter : "com.scwang.smart:refresh-footer-classics:${depsVersion.refreshFooter}",
eventbus : "org.greenrobot:eventbus:${depsVersion.eventbus}",
agentweb : "com.just.agentweb:agentweb:${depsVersion.agentweb}",
arouterapi : "com.alibaba:arouter-api:${depsVersion.arouterapi}",
aroutercompiler : "com.alibaba:arouter-compiler:${depsVersion.aroutercompiler}",
]
}
項目功能詳細概述(所用知識點)
首頁重點概述以下功能的實現:
- 下拉刷新、攜程二樓
- 搜索appBar
- 漸變色網格導航
- banner組件
- 多狀態的tab指示器 (滾動固定頂部)
下拉刷新、攜程二樓
首先,看看具體的效果圖,如圖:
下拉刷新和攜程二樓是使用 smartRefreshLayout 插件完成的,實現代碼如下:
private void initRefreshMore() {
homeHeader.setRefreshHeader(new ClassicsHeader(getContext()), -1, (int) Utils.dp2px(76)); //設置下拉刷新及二樓header的高度
homeHeader.setFloorRate(1.6f); //設置二樓觸發比率
homeRefreshContainer.setPrimaryColorsId(R.color.colorPrimary, R.color.white); //設置下拉刷新及二樓提示文字顏色
homeRefreshContainer.setOnMultiListener(new SimpleMultiListener() {
@Override
public void onLoadMore(@NonNull RefreshLayout refreshLayout) {
loadMore(refreshLayout); //加載更多
}
@Override
public void onRefresh(@NonNull RefreshLayout refreshLayout) {
refreshLayout.finishRefresh(1600); //設置下拉刷新延遲
}
@Override
public void onHeaderMoving(RefreshHeader header, boolean isDragging, float percent, int offset, int headerHeight, int maxDragHeight) {
homeSecondFloorImg.setVisibility(View.VISIBLE); //隱藏二樓背景圖
homeSearchBarContainer.setAlpha(1 - Math.min(percent, 1)); //改變searchBar透明度
}
@Override
public void onStateChanged(@NonNull RefreshLayout refreshLayout, @NonNull RefreshState oldState, @NonNull RefreshState newState) {
if (oldState == RefreshState.ReleaseToTwoLevel) { //即將去往二樓狀態處理
homeSecondFloorImg.setVisibility(View.GONE);
homeHeaderContent.animate().alpha(1).setDuration(666);
} else if (newState == RefreshState.PullDownCanceled) { //下拉取消狀態處理
homeHeaderContent.animate().alpha(0).setDuration(666);
} else if (newState == RefreshState.Refreshing) { //正在刷新狀態處理
homeHeaderContent.animate().alpha(0).setDuration(666);
} else if (oldState == RefreshState.TwoLevelReleased) { // 準備去往二樓完成狀態處理,這裏打開webview
WebViewImpl.getInstance().gotoWebView("https://m.ctrip.com/webapp/you/tsnap/secondFloorIndex.html?isHideNavBar=YES&s_guid=feb780be-c55a-4f92-a6cd-2d81e04d3241", true);
homeHeader.finishTwoLevel();
} else if (oldState == RefreshState.TwoLevel) { //到達二樓狀態處理
homeCustomScrollView.setVisibility(View.GONE);
homeHeaderContent.animate().alpha(0).setDuration(666);
} else if (oldState == RefreshState.TwoLevelFinish) { //二樓完成狀態處理
homeCustomScrollView.setVisibility(View.VISIBLE);
homeCustomScrollView.animate().alpha(1).setDuration(666);
}
}
});
}
XML
頁面佈局文件代碼如下:
<com.scwang.smart.refresh.layout.SmartRefreshLayout
android:id="@+id/home_refresh_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
app:srlAccentColor="@color/colorPrimary"
app:srlPrimaryColor="@color/colorPrimary">
<com.scwang.smart.refresh.header.TwoLevelHeader
android:id="@+id/home_header"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="top">
<ImageView
android:id="@+id/home_second_floor_img"
android:layout_width="match_parent"
android:layout_height="460dp"
android:layout_alignTop="@+id/home_header"
android:scaleType="fitXY"
android:src="@drawable/second_floor"
android:visibility="gone"/>
<FrameLayout
android:id="@+id/home_header_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitXY"
android:src="@drawable/second_floor" />
</FrameLayout>
</com.scwang.smart.refresh.header.TwoLevelHeader>
...
<com.scwang.smart.refresh.footer.ClassicsFooter
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.scwang.smart.refresh.layout.SmartRefreshLayout>
具體實現詳情,可移步 GitHub 查看源碼。
搜索appBar
搜索欄的滾動的 placeholder 文字是使用 banner 插件實現的,點擊搜索框可跳轉到搜索頁面 (flutter寫的搜索頁面) ,跳轉頁面後可以把 placeholder 文字帶到 flutter 搜索頁面。
效果如圖:
滾動的placeholder文字實現代碼如下 (搜索框的實現就不再這裏展示都是一些XML佈局代碼):
homeSearchBarPlaceholder
.setAdapter(new HomeSearchBarPlaceHolderAdapter(homeData.getSearchPlaceHolderList())) // 設置適配器
.setOrientation(Banner.VERTICAL) // 設置滾動方向
.setDelayTime(3600) // 設置間隔時間
.setOnBannerListener(new OnBannerListener() {
@Override
public void OnBannerClick(Object data, int position) { //點擊打開 flutter 搜索頁面
ARouter.getInstance()
.build("/home/search")
.withString("placeHolder", ((Home.SearchPlaceHolderListBean) data).getText())
.navigation();
}
});
}
searchBar的具體功能不過多闡述,和之前的項目一致。
漸變色網格導航
漸變色網格導航基本都是一些 XML
頁面佈局代碼,只是我把它封裝成了單獨的組件,效果如圖
封裝之後的引入就非常簡單,代碼如下:
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/white">
<!-- 網格導航 -->
<net.lishaoy.ft_home.GridNavView
android:id="@+id/home_grid_nav_container"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
...
</LinearLayout>
具體實現詳情,可移步 GitHub 查看源碼。
banner組件
banner組件也是用 banner 插件實現的,如圖
實現代碼如下:
private void initBanner() {
homeBanner.addBannerLifecycleObserver(this)
.setAdapter(new HomeBannerAdapter(homeData.getBannerList())) //設置適配器
.setIndicator(new EllipseIndicator(getContext())) //設置指示器,如圖的指示器是我自定義的插件裏並沒有提供
.setIndicatorSelectedColorRes(R.color.white) //設置指示器顏色
.setIndicatorSpace((int) BannerUtils.dp2px(10)) //設置間距
.setBannerRound(BannerUtils.dp2px(6)); //設置圓角
}
多狀態的tab指示器
多狀態的tab指示器的實現需要注意很多細節,因爲它是在首頁的 fragment
的 ScrollView
裏嵌入 viewPaper
,首先你會發現 viewPaper 不顯示的問題,其次是滾動不流暢的問題,這兩個問題我的解決方案是:
- viewPaper 不顯示的問題:使用自定義的
ViewPager
重寫onMeasure
方法,重新計算高度 - 滾動不流暢的問題:使用自定義的
ScrollView
,重寫computeScroll
和onScrollChanged
重新獲取滾動距離
實現效果如圖:
這個功能實現代碼過多不便在這裏展示,具體實現詳情,可移步 GitHub 查看源碼。
Android Flutter 混合開發
這個項目的實現只有首頁是用 Android 原生實現,其他的頁面均是 Flutter 實現的,之前 純Flutter項目。
Android 引入 Flutter 進行混合開發,需要以下幾個步驟
- 建立一個flutter module
- 編寫flutter代碼 (創建 flutter 路由)
- flutter 和 android 之間相互通信
下面依次概述這幾部分是如何操作實現的。
建立一個flutter module
這個應該不用過多描述,基本操作大家都會 File --> New --> New Module 如圖:
新建完成之後,android studio 會自動生成配置代碼到 gradle 配置文件裏,且生成一個 flutter 的 library 模塊。
Tips:
新建的時候最好 flutter module 和 android 項目放到同級目錄下;
新版的 android studio 纔會自動生成 gradle 配置代碼,老版本貌似需要手動配置
如,沒有生成 gradle 配置代碼,你需要在根項目的 settings.gradle
文件裏手動加入如下配置:
setBinding(new Binding([gradle: this]))
evaluate(new File(
settingsDir, //設置根路徑,根據具體flutter module路徑配置
'flutter_module/.android/include_flutter.groovy'
))
include ':flutter_module'
還需在宿主工程 (沒改名的話都是app) 的 build.gradle
引入 flutter, 如下:
dependencies {
...
//引入flutter模塊
implementation project(':flutter')
...
}
編寫flutter代碼
編寫flutter代碼,在 flutter module 裏按照正常 flutter 開發流程編寫 flutter 代碼即可。 (我項目裏的 flutter 的代碼是之前項目都寫好的,複製過來,改改包的引入問題,就可以運行了。)
這裏需要注意的是,flutter 有且只有一個入口,就是 main()
函數,我們需要在這裏處理好 flutter 頁面的跳轉問題。
在 android 端,創建 flutter 頁面,代碼如下:
Flutter.createView(getActivity(),getLifecycle(),"destination");
Flutter.createView
需要3個參數 activity
、lifecycle
、route
,這個 route 就是要傳遞到 flutter 端的,當然,它是 String 類型的,我們可以自由發揮傳遞普通字符串或 json 字符串等。
我們也可以通過其他的方式創建 flutter 頁面,如: Flutter.createFragment()
、 FlutterActivity.withNewEngine()
、 FlutterFragment.createDefault()
等。
具體的使用,可前往 Flutter官方文檔 查閱。
那麼,flutter 端如何接收這個 route 參數,是通過 window.defaultRouteName
,此項目裏管理 flutter 端路由代碼如下:
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter model',
theme: ThemeData(
primarySwatch: Colors.blue,
fontFamily: 'PingFang',
),
home: _widgetRoute(window.defaultRouteName), // 通過 window.defaultRouteName 接收 android 端傳來的參數
);
}
}
Widget _widgetRoute(String defaultRouteName) {
Map<String, dynamic> params = convert.jsonDecode(defaultRouteName); //解析參數
defaultRouteName = params['routeName'];
placeHolder = params['placeHolder'];
switch (defaultRouteName) { // 根據參數返回對應的頁面
...
case 'destination/search':
return DestinationSearchPage(
hideLeft: false,
);
...
default:
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('not found $defaultRouteName',
textDirection: TextDirection.ltr),
],
),
);
}
}
其實,flutter 端接收這個 route 參數,還有一種方法,就是通過 onGenerateRoute
,它是 MaterialApp 裏的一個方法。
代碼如下:
onGenerateRoute: (settings){ //通過 settings.name 獲取android端傳來的參數
return _widgetRoute(settings.name);
},
flutter 和 android 之間相互通信
flutter 端可以調用 android 端的方法及相互傳遞數據是如何實現的,flutter 官方提供了3個方法可以實現,分別是:
- EventChannel:單向的持續通信,如:網絡變化、傳感器等。
- MethodChannel:一次性通信,一般適用如方法的調用。
- BasicMessageChannel:持續的雙向通信。
此項目裏採用了 MethodChannel
方法進行通信,如:flutter 端調用 android 端的AI智能語音方法以及 flutter 打開 android 端頁面就是用 MethodChannel
實現的。
flutter 端調用 android 端的AI智能語音方法代碼如下:
class AsrManager {
static const MethodChannel _channel = const MethodChannel('lib_asr');
//開始錄音
static Future<String> start({Map params}) async {
return await _channel.invokeMethod('start', params ?? {});
}
//停止錄音
...
//取消錄音
...
//銷燬
...
}
flutter 打開 android 端頁面代碼如下:
class MethodChannelPlugin {
static const MethodChannel methodChannel = MethodChannel('MethodChannelPlugin');
static Future<void> gotoDestinationSearchPage() async {
try {
await methodChannel.invokeMethod('gotoDestinationSearchPage'); //gotoDestinationSearchPage 參數會傳到android端
} on PlatformException {
print('Failed go to gotoDestinationSearchPage');
}
}
...
}
android 接收也是通過 MethodChannel
,具體實現代碼如下:
public class MethodChannelPlugin implements MethodChannel.MethodCallHandler {
private static MethodChannel methodChannel;
private Activity activity;
private MethodChannelPlugin(Activity activity) {
this.activity = activity;
}
//調用方通過 registerWith 來註冊flutter頁面
public static void registerWith(FlutterView flutterView) {
methodChannel = new MethodChannel(flutterView, "MethodChannelPlugin");
MethodChannelPlugin instance = new MethodChannelPlugin((Activity) flutterView.getContext());
methodChannel.setMethodCallHandler(instance);
}
@Override
public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
if (methodCall.method.equals("gotoDestinationSearchPage")) { // 收到消息進行具體操作
EventBus.getDefault().post(new GotoDestinationSearchPageEvent());
result.success(200);
}
...
else {
result.notImplemented();
}
}
}