Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> Android資訊 >> Android 路由設計最佳實踐

Android 路由設計最佳實踐

日期:2017/8/18 9:19:13      編輯:Android資訊

引子

這篇文章會告訴你

  • 什麼是路由,是為了解決什麼問題才產生的
  • 業界現狀是怎麼樣的,我們可以做什麼來優化當前的問題
  • 路由設計思路是怎麼樣的,該怎麼設計比較好
  • 如何用注解實現路由表
  • URL的參數如何依賴注入到Activity、Fragement
  • 如何HookOnActivityResult,不需要再進行requstCode判斷
  • 如何異步攔截路由,實現線程切換,不阻塞頁面跳轉
  • 如何用Apt實現Retrofit接口式調用
  • 如何找到Activity的調用方
  • 如何實現路由的安全調用
  • 如何避開Apt不能匯總所有Module路由的問題

前言

當前Android的路由庫實在太多了,剛開始的時候想為什麼要用路由表的庫,用Android原生的Scheme碼不就好了,又不像iOS只能類依賴,後面越深入就越發現當時想的太簡單了,後面看到Retrofit和OKHttp,才想到頁面請求本質和網絡請求不是一樣嗎,終於業界最簡單高效的路由方案1.0出來了

OkDeepLink

背景

什麼是路由

根據路由表頁面請求分發到指定頁面

使用場景

  1. App接收到一個通知,點擊通知打開App的某個頁面
  2. 浏覽器App中點擊某個鏈接打開App的某個頁面
  3. 運營活動需求,動態把原生的頁面替換成H5頁面
  4. 打開頁面需要某些條件,先驗證完條件,再去打開那個頁面
  5. 不合法的打開App的頁面被屏蔽掉
  6. H5打開鏈接在所有平台都一樣,方便統一跳轉
  7. App存在就打開頁面,不存在就去下載頁面下載,只有Google的App Link支持

為什麼要有路由

Android原生已經支持AndroidManifest去管理App跳轉,為什麼要有路由庫,這可能是大部分人接觸到Android各種Router庫不太明白的地方,這裡我講一下我的理解

  • 顯示Intent:項目龐大以後,類依賴耦合太大,不適合組件化拆分
  • 隱式Intent:協作困難,調用時候不知道調什麼參數
  • 每個注冊了Scheme的Activity都可以直接打開,有安全風險
  • AndroidMainfest集中式管理比較臃腫
  • 無法動態修改路由,如果頁面出錯,無法動態降級
  • 無法動態攔截跳轉,譬如未登錄的情況下,打開登錄頁面,登錄成功後接著打開剛才想打開的頁面
  • H5、Android、iOS地址不一樣,不利於統一跳轉

怎麼樣的路由才算好路由

路由說到底還是為了解決開發者遇到的各種奇葩需求,使用簡單、侵入性低、維護方便是首要條件,不影響你原來的代碼,寫入代碼也很少,這裡就要說說我的OkDeepLink的五大功能了,五大功能瞬間擊中你的各種痛點,早點下班不是夢。

  • 編譯時注解,實現靜態路由表,不再需要在臃腫的AndroidManifest中找到那個Actvity寫Scheme和Intent Filter
  • 異步攔截器,實現動態路由,安全攔截、動態降級難不倒你
  • 模仿Retrofit接口式調用,實現方式用apt,不耗性能,參數調用不再是問題
  • HookOnActivityResult,支持RxJava響應式調用,不再需要進行requestCode判斷
  • 參數依賴注入,自動保存,不再需要手動寫onSaveInstanceonCreate(SaveInstace)onNewIntent(Intent)getQueryParamer

注冊路由

具體使用見OkDeepLink

路由結構圖

詳細比較

大部分路由庫都用Apt(編譯時注解)生成路由表,然後用路由表轉發到指定頁面

方案對比 OkDeepLink Airbnb DeepLinkDispatch 阿裡 ARouter 天貓 統跳協議 ActivityRouter 路由注冊 注解式接口注冊 每個module都要手動注冊 每個module的路由表都要類查找 AndroidManiFest配置 每個module都要手動注冊 路由查找 路由表 路由表 路由表 系統Intent 路由表 路由分發 Activity轉發 Activity轉發 Activity轉發 Activity轉發 Activity轉發 動態替換 Rxjava實現異步攔截器 不支持 線程等待 不支持 不支持 動態攔截 Rxjava實現異步攔截器 不支持 線程等待 不支持 主線程 安全攔截 Rxjava實現異步攔截器 不支持 線程等待 不支持 主線程 方法調用 接口 手動拼裝 手動拼裝 手動拼裝 手動拼裝 參數獲取 Apt依賴注入,支持所有類型,不需要在Activity的onCreate中手動調用get方法 參數定義在path,不利於多人協作 Apt依賴注入,但是要手動調用get方法 手動調用 手動調用 結果返回 Rxjava回調 onActivityResult onActivityResult onActivityResult onActivityResult Module接入不同App 支持 不支持 支持 不支持 支持

其實說到底,路由的本質就是注冊再轉發,圍繞著轉發可以進行各種操作,攔截,替換,參數獲取等等,其他Apt、Rxjava說到底都只是為了方便使用出現的,這裡你會發現各種路由庫反而為了修復各種工具帶來的問題,出現了原來沒有的問題,譬如DeepLinkDispatch為了解決Apt沒法匯總所有Module路由,每個module都要手動注冊,ARouter為了解決Apt沒法匯總所有Module路由,通過類操作耗時,才出現分組的概念。

原理分析

原理流程圖

定義路由

路由定義

我這邊是完全按照URL規范了,這裡要說一下,現在好多方法是把參數定義在path裡面的,雖然這樣做,有不需要額外傳參數的好處,但是這樣路由就沒有那麼靈活,調試起來就沒有那麼方便了。
建議有好幾款app的公司,host都一樣,只有scheme不一樣,這樣只要替換Scheme就能實現降級,維護也簡單。

路由注冊

AndroidManifest裡面的acitivity聲明scheme碼是不安全的,所有App都可以打開這個頁面,這裡就產生有兩種方式去注冊,

  • 注解產生路由表,通過DispatchActivity轉發
  • AndroidManifest注冊,將其export=fasle,但是再通過DispatchActivity轉發Intent,天貓就是這麼做的,比上面的方法的好處是路由查找都是系統調用,省掉了維護路由表的過程,但是AndroidManifest配置還是比較不方便的

我現在還是采用了注解,後面我會結合兩種方法,將注解自動修改AndroidManifest,對於接入方是沒有變動的,方法已經找到了,用自定義Lint掃描出注解相關的Activity,然後用processManifestTask修改Manifest,有個demo了,後面會接入。

生成路由表

譬如通過Apt把這段代碼

public interface SampleService {

    @Path("/main")
    @Activity(MainActivity.class)
    void startMainActivity(@Query("key") String key);
}

生成

@After("execution(* okdeeplink.DeepLinkClient.init(..))")
  public void init() {
    DeepLinkClient.addAddress(new Address("/main", MainActivity.class));
  }

初始化路由表

這裡就要提一下使用Apt會造成每個module都要手動注冊

DeepLinkDispatch是這麼做的

@DeepLinkModule
public class SampleModule {
}
@DeepLinkHandler({ SampleModule.class, LibraryDeepLinkModule.class })
public class DeepLinkActivity extends Activity {
  @Override protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    DeepLinkDelegate deepLinkDelegate = new DeepLinkDelegate(
        new SampleModuleLoader(), new LibraryDeepLinkModuleLoader());
    deepLinkDelegate.dispatchFrom(this);
    finish();
  }
}

ARouter是通過類查找,就比較耗時了,所以他又加入了分組的概念,按需加載

/**
     * 通過指定包名,掃描包下面包含的所有的ClassName
     *
     * @param context     U know
     * @param packageName 包名
     * @return 所有class的集合
     */
    public static List<String> getFileNameByPackageName(Context context, String packageName) throws PackageManager.NameNotFoundException, IOException {
        List<String> classNames = new ArrayList<>();
        for (String path : getSourcePaths(context)) {
            DexFile dexfile = null;

            try {
                if (path.endsWith(EXTRACTED_SUFFIX)) {
                    //NOT use new DexFile(path), because it will throw "permission error in /data/dalvik-cache"
                    dexfile = DexFile.loadDex(path, path + ".tmp", 0);
                } else {
                    dexfile = new DexFile(path);
                }
                Enumeration<String> dexEntries = dexfile.entries();
                while (dexEntries.hasMoreElements()) {
                    String className = dexEntries.nextElement();
                    if (className.contains(packageName)) {
                        classNames.add(className);
                    }
                }
            } catch (Throwable ignore) {
                Log.e("ARouter", "Scan map file in dex files made error.", ignore);
            } finally {
                if (null != dexfile) {
                    try {
                        dexfile.close();
                    } catch (Throwable ignore) {
                    }
                }
            }
        }

        Log.d("ARouter", "Filter " + classNames.size() + " classes by packageName <" + packageName + ">");
        return classNames;
    }

ActivityRouter就比較巧妙了,通過Stub項目,其他地方都是provide的,只有主工程裡面用Apt生成RouterInit類,雖然還是要寫module的注解

        // RouterInit
        if (hasModules) {
            debug("generate modules RouterInit");
            generateModulesRouterInit(moduleNames);
        } else if (!hasModule) {
            debug("generate default RouterInit");
            generateDefaultRouterInit();
        }

天貓 統跳協議 是最簡單的,轉發一下Intent就可以,但是這樣就沒法享受注解的好處了。

而我用aspectj解決了這個問題,會自動匯總所有module的路由省略了這些多余的代碼,或者有誰知道用Apt自生怎麼解決,請聯系我一下。

@After("execution(* okdeeplink.DeepLinkClient.init(..))")
  public void init() {
    DeepLinkClient.addAddress(new Address("/main", MainActivity.class));
  }

路由查找

路由查找就是查找路由表對應的頁面,值得提起的就是因為要適應Module接入不同App,Scheme要自動適應,路由表其實是Path—》Activity,這樣的話內部跳轉的時候ARouterUri是沒有的。而我這邊是有的,我組裝了一個內部的Uri,這樣攔截器不會有影響。

public Request buildRequest(Intent sourceIntent) {
        if (sourceIntent == null) {
            return null;
        }
        Intent newIntent = new Intent(sourceIntent);
        Uri uri = newIntent.getData();

        addNewTaskFlag(newIntent);

        if (uri != null) {
            addBundleQuery(newIntent, uri);

            Address entry = new DeepLinkClient(context).matchUrl(uri.toString());
            if (entry == null || entry.getActivityClass() == null) {
                return new Request(newIntent, this).setDeepLink(false);
            }
            newIntent.setComponent(new ComponentName(context, entry.getActivityClass()));

            return new Request(newIntent, this);
        }
        return new Request(newIntent, this).setDeepLink(false);

    }

路由分發

現在所有路由方案分發都是用Activity做分發的,這樣做會有這幾個缺點

  1. 每次都要啟動一個Activity,而Activity就算不寫任何代碼啟動都要0.1秒
  2. 如果是異步等待的話,Activiy要在合適時間finish,不然會有一層透明的頁面阻擋操作

對於第一個問題,有兩個方法

  1. QQ音樂是把DispatchActivity設為SingleInstacne,但是這樣的話,動畫會奇怪,堆棧也會亂掉,後退會有一層透明的頁面阻擋操作
  2. DispatchActivity只在外部打開的時候調用

我選擇了第二種

對於第二個問題,有兩個方法

  1. DispatchActivity再把Intent轉發到Service,再finish,這種方法唯一的缺陷是攔截器裡面的context是Servcie的activity,就沒發再攔截器裡面彈出對話框了。
  2. DispatchActivity在打開和錯誤的時候finish,如果activity已經finish了,就用application的context去轉發路由

我選擇了第二種

  public void dispatchFrom(Intent intent) {
        new DeepLinkClient(this)
                .buildRequest(intent)
                .dispatch()
                .subscribe(new Subscriber<Request>() {
                    @Override
                    public void onCompleted() {
                        finish();
                    }

                    @Override
                    public void onError(Throwable e) {
                        finish();
                    }

                    @Override
                    public void onNext(Request request) {
                        Intent dispatchIntent = request.getIntent();
                        startActivity(dispatchIntent);
                    }
                });
    }

其實處理透明Activity阻擋操作可以采用取消所有事件變成無感頁面的方法,但是還是覺得會影響activity堆棧沒有采用這種方案

getwindow().addflags( windowmanager.layoutparams.flag_not_focusable
| windowmanager.layoutparams.flag_not_touch_modal 
| windowmanager.layoutparams.flag_not_touchable);

結果返回

這裡我封裝了一個庫RxActivityResult去捕獲onActivityResult,這樣能保正流式調用

譬如拍照可以這樣寫,先定義一個接口

    public interface ImageCaptureService {

    @Action(MediaStore.ACTION_IMAGE_CAPTURE)
    Observable<Response> startImageCapture();
}

然後這樣調用

public class MainActivity extends AppCompatActivity {

    @Service
    ImageCaptureService imageCaptureService;

    public void captureImage(){
        imageCaptureService
                .startImageCapture()
                .subscribe(new Action1<Response>() {
                    @Override
                    public void call(Response response) {
                        Intent data = response.getData();
                        int resultCode = response.getResultCode();
                        if (resultCode == RESULT_OK) {
                            Bitmap imageBitmap = (Bitmap) data.getExtras().get("data");
                        }
                    }
                });
    }
}
}

是不是很簡單,原理是這樣的,通過封裝一個RxResultHoldFragment去處理onActivityResult

private IActivityObservable buildActivityObservable() {

            T target = targetWeak.get();

            if (target instanceof FragmentActivity) {
                FragmentActivity activity = (FragmentActivity) target;
                android.support.v4.app.FragmentManager fragmentManager = activity.getSupportFragmentManager();
                IActivityObservable activityObservable = RxResultHoldFragmentV4.getHoldFragment(fragmentManager);
                return activityObservable;
            }

            if (target instanceof Activity) {
                Activity activity = (Activity) target;
                FragmentManager fragmentManager = activity.getFragmentManager();
                IActivityObservable activityObservable = RxResultHoldFragment.getHoldFragment(fragmentManager);
                return activityObservable;
            }
            if (target instanceof Context) {
                final Context context = (Context) target;
                IActivityObservable activityObservable = new RxResultHoldContext(context);
                return activityObservable;
            }

            if (target instanceof Fragment) {
                Fragment fragment = (Fragment) target;
                FragmentManager fragmentManager = fragment.getFragmentManager();
                if (fragmentManager != null) {
                    IActivityObservable activityObservable = RxResultHoldFragment.getHoldFragment(fragmentManager);
                    return activityObservable;
                }
            }
            if (target instanceof android.support.v4.app.Fragment) {
                android.support.v4.app.Fragment fragment = (android.support.v4.app.Fragment) target;
                android.support.v4.app.FragmentManager fragmentManager = fragment.getFragmentManager();
                if (fragmentManager != null) {
                    IActivityObservable activityObservable = RxResultHoldFragmentV4.getHoldFragment(fragmentManager);
                    return activityObservable;
                }
            }
            return new RxResultHoldEmpty();
        }

動態攔截

攔截器是重中之重,有了攔截器可以做好多事情,可以說之所以要做頁面路由,就是為了要實現攔截器。ARouter是用線程等待實現的,但是現在有Rxjava了,可以實現更優美的方式。
先來看一下我做的攔截器的效果.

@Intercept(path = "/second")
public class SecondInterceptor extends Interceptor {
    @Override
    public void intercept(final Call call) {

        Request request = call.getRequest();
        final Intent intent = request.getIntent();
        Context context = request.getContext();

        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append("Intercept\n");
        stringBuffer.append("URL: " + request.getUrl() + "\n");

        AlertDialog.Builder builder = new AlertDialog.Builder(context,R.style.Theme_AppCompat_Dialog_Alert);
        builder.setTitle("Notice");
        builder.setMessage(stringBuffer);
        builder.setNegativeButton("cancel", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                call.cancel();
            }
        });
        builder.setPositiveButton("ok", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                intent.putExtra("key1", "value3");
                call.proceed();
            }
        });
        builder.setOnDismissListener(new DialogInterface.OnDismissListener() {
            @Override
            public void onDismiss(DialogInterface dialog) {
                call.cancel();
            }
        });
        builder.show();
    }
}

是不是很簡單,參考了部分OkHttp的實現思路,加入Rxjava,實現異步攔截。

首先將請求轉換成責任鏈模式RealCallChain,RealCallChain的call方法實際不會執行路由跳轉,只有Interceptor裡面調用了call.proceed或者call.cancel才會執行.

    private Observable<Request> buildRequest() {
        RealCallChain chain = new RealCallChain(interceptors, 0, request);
        chain.setTimeout(interceptTimeOut);
        chain.call();
        return chain
                .getRequestObservable()
                .map(new Func1<Request, Request>() {
                    @Override
                    public Request call(Request request) {
                        if (interceptors != null) {
                            for (Interceptor interceptor : interceptors) {
                                interceptor.onCall(request);
                            }
                        }
                        return request;
                    }
                });
    }

接著處理異步的問題,這裡用到了Rxjava的AsyncSubject和BehaviorSubject,

  1. AsyncSubject具有僅釋放Observable釋放的最後一個數據的特性,作為路由請求的發送器
  2. BehaviorSubject具有一開始就會釋放最近釋放的數據的特性,作為路由攔截器的發送器

具體實現看核心代碼

    @Override
    public void proceed() {

        if (index >= interceptors.size()) {
            realCall();
            return;
        }
        final Interceptor interceptor = interceptors.get(index);
        Observable
                .just(1)
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new Action1<Integer>() {
                    @Override
                    public void call(Integer integer) {
                        interceptor.intercept(RealCallChain.this);
                    }
                });

        interceptorSubject.onNext(interceptor);
        index = index + 1;
    }

方法調用

大部分路由庫都是手動拼參數調用路由的,這裡模仿了Retrofit接口式調用,受了LiteRouter的啟發,不過Retrofit使用了動態代理,我使用的Apt沒有性能損耗。

通過Apt生成每個接口的實際方法

譬如把SecondService接口

public interface SecondService {

    @Path("/second")
    @Activity(SecondActivity.class)
    void startSecondActivity();
}

生成

@Aspect
public final class SecondService$$Provider implements SecondService {
  public DeepLinkClient deepLinkClient;

  public SecondService$$Provider(DeepLinkClient deepLinkClient) {
    this.deepLinkClient= deepLinkClient;
  }
  @Override
  public void startSecondActivity() {
    Intent intent = new Intent();
    intent.setData(Uri.parse("app://deeplink/second"));
    Request request = deepLinkClient.buildRequest(intent);
    if (request != null) {
      request.start();
    }
  }

  @Around("execution(* okdeeplink.DeepLinkClient.build(..))")
  public Object aroundBuildMethod(ProceedingJoinPoint joinPoint) throws Throwable {
    DeepLinkClient target = (DeepLinkClient)joinPoint.getTarget();
    if (joinPoint.getArgs() == null || joinPoint.getArgs().length != 1) {
      return joinPoint.proceed();
    }
    Object arg = joinPoint.getArgs()[0];
    if (arg instanceof Class) {
      Class buildClass = (Class) arg;
      if (buildClass.isAssignableFrom(getClass())) {
        return new SecondService$$Provider(target);
      }
    }
    return joinPoint.proceed();
  }
}

然後調用

SecondService secondServicenew = DeepLinkClient(target).build(SecondService.class);

SecondService就生成了。
為了調用方便,直接在Activity或者fragement寫這段代碼,sampleServive就自動生成了

  @Service
  SampleService sampleService;

但是如果用到MVP模式,不是在Activity裡面調用路由,後面會支持在這些類裡面自動注入SampleService,現在先用java代碼build

參數獲取

大部分路由庫都是手動獲取參數的,這樣還要傳入參數key比較麻煩,這裡模仿了ARouter,不過我支持類型更全一些,支持Bundle支持的所有類型,而且不需要在Acitivty的onCreate調用獲取代碼。
通過Apt把這段代碼

public class MainActivity extends AppCompatActivity {

    @Query("key")
    String key;
}

生成

@Aspect
public class MainActivity$$Injector {
  @Around("execution(* okdeeplink.sample.MainActivity.onCreate(..))")
  public void onCreate(ProceedingJoinPoint joinPoint) throws Throwable {
    MainActivity target = (MainActivity)joinPoint.getTarget();
    Bundle dataBundle = new Bundle();
    Bundle saveBundle = (Bundle)joinPoint.getArgs()[0];
    Bundle targetBundle = BundleCompact.getSupportBundle(target);
    if(targetBundle != null) {
      dataBundle.putAll(targetBundle);
    }
    if(saveBundle != null) {
      dataBundle.putAll(saveBundle);
    }
    try {
      target.key= BundleCompact.getValue(dataBundle,"key",String.class);
    } catch (Exception e) {
      e.printStackTrace();
    }
    joinPoint.proceed();
  }

  @After("execution(* okdeeplink.sample.MainActivity.onSaveInstanceState(..))")
  public void onSaveInstanceState(JoinPoint joinPoint) throws Throwable {
    MainActivity target = (MainActivity)joinPoint.getTarget();
    Bundle saveBundle = (Bundle)joinPoint.getArgs()[0];
    Intent intent = new Intent();
    intent.putExtra("key",target.key);
    saveBundle.putAll(intent.getExtras());
  }

  @Around("execution(* okdeeplink.sample.MainActivity.onNewIntent(..))")
  public void onNewIntent(ProceedingJoinPoint joinPoint) throws Throwable {
    MainActivity target = (MainActivity)joinPoint.getTarget();
    Intent targetIntent = (Intent)joinPoint.getArgs()[0];
    Bundle dataBundle = targetIntent.getExtras();
    try {
      target.key= BundleCompact.getValue(dataBundle,"key",String.class);
    } catch (Exception e) {
      e.printStackTrace();
    }
    joinPoint.proceed();
  }
}

Module接入不同App

這裡是參考ARouter把path作為key對應activity,這樣接入到其他app中,就自動替換了scheme碼

DeepLinkClient.addAddress(new Address("/main", MainActivity.class));

安全

現在有好多人用腳本來打開App,然後干壞事,其實時可以用路由來屏蔽掉.

有三種方法供君選擇,不同方法適合不同場景

簽名屏蔽

就是把所有參數加密成一個數據作為sign參數,然後比對校驗,但是這要求加密方法不變,要不然升級了以前的app就打不開了

adb打開屏蔽

在android5.1手機上,用adb打開的app它的mReferrer為空

 public boolean isStartByAdb(android.app.Activity activity){
        if (Build.VERSION.SDK_INT >= 22) {
            android.net.Uri uri = ActivityCompat.getReferrer(activity);
            return uri == null | TextUtils.isEmpty(uri.toString()) ;
        }
        return false;
    }

包名過濾

在Android 4.4手機上, 寫了android:ssp的組件,只有特定應用可以打開

<activity
            android:name="okdeeplink.DeepLinkActivity"
            android:noHistory="true"
            android:theme="@android:style/Theme.Translucent.NoTitleBar">
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />

                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />

                <data
                    android:ssp="com.app.test"
                    android:host="app"
                    android:scheme="odl" />
            </intent-filter>
        </activity>

這三種方法,比較適合的還是簽名校驗為主,adb過濾為副

如何解決路由造成的Activity堆棧錯亂的問題

activity的launchMode使用不當會照成閃屏頁面打開多次的問題,可以參考我這篇文章。

未來展望

路由是一個基礎模塊,技術難度雖然不是很大,但是如果每個開發都重新踩一遍,性價比就比較低,我希望能把路由相關的所有鏈路都替你弄好,你可以留著時間去干其他更重要的事情,譬如陪陪家人,逗逗狗什麼的。
接下來我會在這幾個方面努力,把整條鏈路補全。

  • 做一個像Swagger的平台,支持一鍵導出所有路由、二維碼打開路由
  • 注解修改AndroidManifest,不再需要路由表
  • 支持路由方法接收器,Url直接打開某個方法,不再局限Activity

如果大家有意見,歡迎聯系我kingofzqj@gmail.com

  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved