Android插件化探索

簡介

對於App而言,所謂的插件化,我的的理解就是把一個完整的App拆分紅宿主和插件兩大部分,咱們在宿主app運行時能夠動態的載入或者替換插件的部分,插件不只是對宿主功能的擴展並且還能減少宿主的負擔,所謂的宿主就是運行的app,插件即宿主運行時加載的apk文件,這樣宿主和插件結合的方案技術大概就是插件化了吧。android

爲何要插件化?

  • 解耦,獨立各大模塊的業務成爲插件,互不干擾,即用即插,方便開發與維護。當業務龐大、繁瑣以後,是否存在牽一髮而動全身的感受,是否存在邏輯過於複雜、耦合度較高、難以掌控整個項目。
  • 加快編譯。每次修改後無需從新編輯整個工程項目,能夠單獨編譯某個插件工程,對於龐大的項目而言,速度就是至上的武功。
  • 動態更新。無需從新下載與安裝app,能夠單獨下載某個插件apk,直接加載,從動態更新、包體積和流量上感受是個不錯的選擇。
  • 模塊定製。須要什麼模塊下載什麼模塊,無需讓app變得龐大,所需所得。

插件化原理簡單描述

關於插件化主要解決的大概就是類加載、資源加載、組件的加載這些核心問題了吧,所謂的原理也就是圍繞這些問題進行的探討。git

Android的類加載

android中的類加載系統的ClassLoader能夠大體劃分爲BaseDexClassLoader,SecureClassLoader。做爲插件化咱們只簡單分析一下PathClassLoader與DexClassLoader,畢竟類加載的內容也不少,要寫的東西也不少😝,先看下android類加載繼承關係圖:github

  • PathClassLoader 提供一個簡單的類加載器實現,該實現對本地文件系統中的文件和目錄列表進行操做,但不嘗試從網絡加載類。Android將該類用於其系統類加載器和應用程序類加載器(簡單講可加載已安裝的apk)。下面是官方的描述:

Provides a simple ClassLoader implementation that operates on a list of files and directories in the local file system, but does not attempt to load classes from the network. Android uses this class for its system class loader and for its application class loader(s).windows

  • DexClassLoader 它從.jar和.apk文件中加載包含類.dex條目的類。這能夠用於執行未做爲應用程序的一部分安裝的代碼(簡單講可加載未安裝的apk,熱修復與動態更新可能就是靠它了)在API級別26以前,這個類加載器須要一個應用程序專用的可寫目錄來緩存優化的類。使用Context.getCodeCacheDir()建立這樣一個目錄:如下爲官方的描述:

A class loader that loads classes from .jar and .apk files containing a classes.dex entry. This can be used to execute code not installed as part of an application. Prior to API level 26, this class loader requires an application-private, writable directory to cache optimized classes. Use Context.getCodeCacheDir() to create such a directory:api

以上關於android的類加載只是輕描淡寫了一下,說了半天,關於插件化固然用到了DexClassLoader,咱們來看一下DexClassLoader的實現吧。數組

public class DexClassLoader extends BaseDexClassLoader {
   public DexClassLoader(String dexPath, String optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
    super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
  }
}
複製代碼

英語不是很好,下面我簡單翻譯一下😝

dexPath: 字符串變量,包含類和資源的jar/apk文件列表,用File.pathSeparator分隔,在Android上默認爲「:」。
optimizedDirectory:不推薦使用此參數,貌似是一個廢棄的參數,聽說是.dex文件的解壓路徑,自API級別26起再也不生效,那麼26以前是怎麼用的呢,查了一下是經過 context.getCodeCacheDir()。
librarySearchPath: 包含native庫的目錄列表,C/C++庫存放的路徑,用File.pathSeparator分隔;可能爲null。
parent: 父類加載器ClassLoader.瀏覽器

再看一下調用的父類BaseDexClassLoader的構造方法及核心方法緩存

public class BaseDexClassLoader extends ClassLoader {
  private final DexPathList pathList;
  public BaseDexClassLoader(String dexPath, File optimizedDirectory,
           String libraryPath, ClassLoader parent) {
       super(parent);
       this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
   }
    @Override
   protected Class<?> findClass(String name) throws ClassNotFoundException {
       List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
       Class c = pathList.findClass(name, suppressedExceptions);
       if (c == null) {
           ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
           for (Throwable t : suppressedExceptions) {
               cnfe.addSuppressed(t);
           }
           throw cnfe;
       }
       return c;
   }
    @Override
   protected URL findResource(String name) {
       return pathList.findResource(name);
   }
    @Override
   protected Enumeration<URL> findResources(String name) {
       return pathList.findResources(name);
   }
    @Override
   public String findLibrary(String name) {
       return pathList.findLibrary(name);
   }
}
複製代碼

顯然看出DexPathList這個成員對象的重要性,初始化構造方法的時候實例化DexPathList對象,同時,BaseDexClassLoader重寫了父類findClass()方法,經過該方法進行類查找的時候,會委託給pathList對象的findClass()方法進行相應的類查找,下面繼續查看DexPathList類的findClass方法:安全

final class DexPathList {
    private Element[] dexElements;
    DexPathList(ClassLoader definingContext, String dexPath,
            String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
        ...
        
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                                           suppressedExceptions, definingContext, isTrusted);
        ...
    }
    private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
            List<IOException> suppressedExceptions, ClassLoader loader, boolean isTrusted) {
      Element[] elements = new Element[files.size()];
      int elementsPos = 0;
      for (File file : files) {
          if (file.isDirectory()) {
              elements[elementsPos++] = new Element(file);
          } else if (file.isFile()) {
              String name = file.getName();

              DexFile dex = null;
              if (name.endsWith(DEX_SUFFIX)) {
                  // Raw dex file (not inside a zip/jar).
                  try {
                      dex = loadDexFile(file, optimizedDirectory, loader, elements);
                      if (dex != null) {
                          elements[elementsPos++] = new Element(dex, null);
                      }
                  } catch (IOException suppressed) {
                      System.logE("Unable to load dex file: " + file, suppressed);
                      suppressedExceptions.add(suppressed);
                  }
              } else {
                  try {
                      dex = loadDexFile(file, optimizedDirectory, loader, elements);
                  } catch (IOException suppressed) {
                      suppressedExceptions.add(suppressed);
                  }

                  if (dex == null) {
                      elements[elementsPos++] = new Element(file);
                  } else {
                      elements[elementsPos++] = new Element(dex, file);
                  }
              }
          } else {
              System.logW("ClassLoader referenced unknown path: " + file);
          }
      }
      return elements;
    }
    public Class findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {
            DexFile dex = element.dexFile;
            if (dex != null) {
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }
}
複製代碼

DexPathList構造方法被調用的時候其實就是經過makeDexElements方法把dexPath進行遍歷,依次加載每一個dex文件,而後經過數組Element[]存放,而在DexPathList類的findClass調用的時候,經過遍歷Element[]的dex文件,在經過DexFile類的loadClassBinaryName()來加載類,若是不爲空那麼表明加載成功,而且返回class,不然返回null。
下面再來看一下基類的ClassLoader是如何實現的吧性能優化

public abstract class ClassLoader {
    private final ClassLoader parent;
    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException{
            Class c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                }
                if (c == null) {
                    c = findClass(name);
                }
            }
            return c;
    }
}
複製代碼

這明顯就是一個雙親委派模型,在類加載的時候,首先去查找這個類以前是否已經被加載過,若是加載過直接返回,不然委託父類加載器去查找,若是父類加載器找不到那麼就去系統的BootstrapClass去查找,到最後仍是找不到的話,那麼就本身親自上陣查找了。這樣就避免了重複加載,實現了更加安全。
好了總結一下DexClassLoader的加載過程:loadClass->findClass->BaseDexClassLoader.findClass->DexPathList.findClass->loadDexFile->DexFile.loadClassBinaryName->DexFile.defineClass,大致上就這樣麼個過程吧。

資源加載

Android系統加載資源都是經過Resource資源對象來進行加載的,所以只須要添加資源(即apk文件)所在路徑到AssetManager中,便可實現對插件資源的訪問。

/**
     * Create a new AssetManager containing only the basic system assets.
     * Applications will not generally use this method, instead retrieving the
     * appropriate asset manager with {@link Resources#getAssets}. Not for
     * use by applications.
     * @hide
     */
    public AssetManager() {
        final ApkAssets[] assets;
        synchronized (sSync) {
            createSystemAssetsInZygoteLocked();
            assets = sSystemApkAssets;
        }

        mObject = nativeCreate();
        if (DEBUG_REFS) {
            mNumRefs = 0;
            incRefsLocked(hashCode());
        }

        // Always set the framework resources.
        setApkAssets(assets, false /*invalidateCaches*/);
    }
複製代碼

不難發現AssetManager的構造方法是@hide隱藏的api,因此不能直接使用,這裏確定是須要經過反射啦,不過有人說Android P不是對系統的隱藏Api作出了限制,所以插件化估計要涼涼,可是我想說如今一些主流的插件化技術基本都已經適配了Android9.0了,因此無需擔憂。下面先簡單貼出Android資源的加載流程。關於插件化的資源加載能夠參考下滴滴VirtualApk資源的加載思想 (傳送門

class ContextImpl extends Context {
//...
    private ContextImpl(ContextImpl container, ActivityThread mainThread,
            LoadedApk packageInfo, IBinder activityToken, UserHandle user, boolean restricted,
            Display display, Configuration overrideConfiguration) {
    //....
    Resources resources = packageInfo.getResources(mainThread);
    //....
    }
//...
}
複製代碼

這裏不去關注packageInfo是如何生成的,直接跟蹤到下面去.

public final class LoadedApk {
  private final String mResDir;
  public LoadedApk(ActivityThread activityThread, ApplicationInfo aInfo,
            CompatibilityInfo compatInfo, ClassLoader baseLoader,
            boolean securityViolation, boolean includeCode, boolean registerPackage) {
        final int myUid = Process.myUid();
        aInfo = adjustNativeLibraryPaths(aInfo);
        mActivityThread = activityThread;
        mApplicationInfo = aInfo;
        mPackageName = aInfo.packageName;
        mAppDir = aInfo.sourceDir;
        mResDir = aInfo.uid == myUid ? aInfo.sourceDir : aInfo.publicSourceDir;
        // 注意一下這個sourceDir,這個是咱們宿主的APK包在手機中的路徑,宿主的資源經過此地址加載。
        // 該值的生成涉及到PMS,暫時不進行分析。
        // Full path to the base APK for this application.
       //....
    }
//....
   public Resources getResources(ActivityThread mainThread) {
        if (mResources == null) {
            mResources = mainThread.getTopLevelResources(mResDir, mSplitResDirs, mOverlayDirs,
                    mApplicationInfo.sharedLibraryFiles, Display.DEFAULT_DISPLAY, null, this);
        }
        return mResources;
    }
//....
}
複製代碼

進入到ActivityThread.getTopLevelResources()的邏輯中

public final class ActivityThread {  
  Resources getTopLevelResources(String resDir, CompatibilityInfo compInfo) {  
  //咱們暫時只關注下面這一段代碼
 
       AssetManager assets = new AssetManager();  
        if (assets.addAssetPath(resDir) == 0) {  //此處將上面的mResDir,也就是宿主的APK在手機中的路徑當作資源包添加到AssetManager裏,則Resources對象能夠經過AssetManager查找資源,此處見(老羅博客:Android應用程序資源的查找過程分析)
            return null;  
        }  
        // 建立Resources對象,此處依賴AssetManager類來實現資源查找功能。
       r = new Resources(assets, metrics, getConfiguration(), compInfo);  
  
 }  
}
複製代碼

從上面的代碼中咱們知道了咱們經常使用的Resources是如何生成的了,那麼理論上插件也就按照如此方式生成一個Resources對象給本身用就能夠了。

組件的加載

這個其實不能一律而論,由於Android擁有四大組件,分別爲Activity、Service、ContentProvider、BoradCastRecevier,每一個組件的屬性及生命週期也不同,因此關於插件中加載的組件就須要分別研究每一個組件是如何加載的了。

簡單拿Activity組件來講,如今一些主流的方式基本上都是經過「坑位」的思想,這個詞最先聽說也是來源於360,總的來講,先佔坑,由於咱們宿主app的Manifest中是不會去申請插件中的Activity的,那我就先佔一個坑,欺騙系統,而後替換成插件中的Activity。這裏可能須要多個坑位,由於一些資源屬性都是能夠動態配置的。好比launchMode、process、configChanges、theme等等。
這裏還須要瞭解一下Activity的啓動流程,這裏咱們能夠簡單看一下。

@Override
    public void startActivity(Intent intent, @Nullable Bundle options) {
        if (options != null) {
            startActivityForResult(intent, -1, options);
        } else {
            // Note we want to go through this call for compatibility with
            // applications that may have overridden the method.
            startActivityForResult(intent, -1);
        }
    }
複製代碼

能夠看出,咱們平時startActivity其實都是經過調用startActivityForResult(),咱們接下來繼續看

public void startActivityForResult(@RequiresPermission Intent intent, int requestCode,
            @Nullable Bundle options) {
        if (mParent == null) {
            options = transferSpringboardActivityOptions(options);
            Instrumentation.ActivityResult ar =
                mInstrumentation.execStartActivity(
                    this, mMainThread.getApplicationThread(), mToken, this,
                    intent, requestCode, options);
            if (ar != null) {
                mMainThread.sendActivityResult(
                    mToken, mEmbeddedID, requestCode, ar.getResultCode(),
                    ar.getResultData());
            }
            if (requestCode >= 0) {
                mStartedActivity = true;
            }
            cancelInputsAndStartExitTransition(options);
            // TODO Consider clearing/flushing other event sources and events for child windows.
        } else {
            if (options != null) {
                mParent.startActivityFromChild(this, intent, requestCode, options);
            } else {
                // Note we want to go through this method for compatibility with
                // existing applications that may have overridden it.
                mParent.startActivityFromChild(this, intent, requestCode);
            }
        }
    }
複製代碼

咱們能夠看到是經過系統的Instrumentation這個類execStartActivity()來執行啓動Activity的,咱們繼續能夠看到下面的這個方法:

public ActivityResult execStartActivity(
  、、、、、
        try {
            intent.migrateExtraStreamToClipData();
            intent.prepareToLeaveProcess(who);
            int result = ActivityManager.getService()
                .startActivity(whoThread, who.getBasePackageName(), intent,
                        intent.resolveTypeIfNeeded(who.getContentResolver()),
                        token, target != null ? target.mEmbeddedID : null,
                        requestCode, 0, null, options);
            checkStartActivityResult(result, intent);
        } catch (RemoteException e) {
            throw new RuntimeException("Failure from system", e);
        }
        return null;
    }
    
 /**
     * @hide
     */
    public static IActivityManager getService() {
        return IActivityManagerSingleton.get();
    }
    private static final Singleton<IActivityManager> IActivityManagerSingleton =
            new Singleton<IActivityManager>() {
                @Override
                protected IActivityManager create() {
                    final IBinder b = ServiceManager.getService(Context.ACTIVITY_SERVICE);
                    final IActivityManager am = IActivityManager.Stub.asInterface(b);
                    return am;
                }
            };
複製代碼

ActivityManager.getService()拿到IActivityManager對象,而後就去調用startActivity()了,而IActivityManager只是一個抽象接口,下面看看它的實現類

public abstract class ActivityManagerNative extends Binder implements IActivityManager

public final class ActivityManagerService extends ActivityManagerNative
        implements Watchdog.Monitor, BatteryStatsImpl.BatteryCallback
        
class ActivityManagerProxy implements IActivityManager
複製代碼

能夠看到它的兩個實現類ActivityManagerProxy與ActivityManagerService,簡稱AMP與AMS,AMP只是AMS的本地代理對象,其startActivity方法會調用到AMS的startActivity方法。並且要注意,這個startActivity方法會把ApplicationThread對象傳遞到AMS所在進程,固然AMS拿到的其實是ApplicationThread的代理對象ApplicationThreadProxy,AMS就要經過這個代理對象與咱們的App進程進行通訊。
既然Activity是否存在的校驗是發生在AMS端,那麼咱們在與AMS交互前,提早將Activity的ComponentName進行替換爲佔坑的名字,選擇hook Instrumentation或者ActivityManagerProxy應該都是能夠的,而後Activity通過複雜的啓動流程後最終會執行Instrumentation的newActivity(),這裏咱們能夠進行還原成插件的Activity。

public Activity newActivity(Class<?> clazz, Context context, 
            IBinder token, Application application, Intent intent, ActivityInfo info, 
            CharSequence title, Activity parent, String id,
            Object lastNonConfigurationInstance) throws InstantiationException, 
            IllegalAccessException {
        Activity activity = (Activity)clazz.newInstance();
        ActivityThread aThread = null;
        // Activity.attach expects a non-null Application Object.
        if (application == null) {
            application = new Application();
        }
        activity.attach(context, aThread, this, token, 0 /* ident */, application, intent,
                info, title, parent, id,
                (Activity.NonConfigurationInstances)lastNonConfigurationInstance,
                new Configuration(), null /* referrer */, null /* voiceInteractor */,
                null /* window */, null /* activityConfigCallback */);
        return activity;
    }
複製代碼

關於插件化四大組件的加載原理過於複雜,我只簡單的描述了一下插件化的思想,若是想看具體的思想流程,也能夠查看滴滴VirtualApk的組件加載原理,插件化思想都有共通之處(傳送門

關於插件化方案的選取

若是你在作插件化,或者想去研究插件化,上面看不懂沒有關係,反正市場上已經擁有很是多的成熟方案,下面是從萬千的方案中挑取較好的幾個方案,以避免走更多的彎路,畢竟我也是從茫茫的插件化方案中走了一遭。

  • VirtualApk 滴滴插件化方案,功能很是強大,並且兼容性強,目前已經適配Android 9.0,若是項目插件和宿主存在依賴的話是個不錯的選擇。
  • DroidPlugin 360的一款插件化方案,最大的特點就是插件獨立,不依賴宿主,固然就無耦合啦
  • RePlugin 360另一款插件化方案,它與DroidPlugin表明2個不一樣方向,各個功能模塊能獨立升級,又能須要和宿主、插件之間有必定交互和耦合。
  • Shadow 騰訊最近剛開源的插件化方案,最大特點零反射,核心庫採起Kotlin實現,我的以爲之後是個不錯的選擇,可是由於剛開源,還未受到大衆的檢測。
  • VirtualApp 羅盒科技的一款運行於Android系統的沙盒產品,能夠理解爲輕量級的「Android虛擬機」,很是的牛,普遍應用於插件化開發、無感知熱更新、雲控自動化、多開、手遊租號、手遊手柄免激活、區塊鏈、移動辦公安全、軍隊政府保密、手機模擬信息、腳本自動化、自動化測試等技術領域,最大的特點app雙開及多開,沙盒能力,內外隔離。不過2017已經商業化了。

滴滴插件化嚐鮮

VirtualAPK的工做過程

VirtualAPK對插件沒有額外的約束,原生的apk便可做爲插件。插件工程編譯生成apk後,便可經過宿主App加載,每一個插件apk被加載後,都會在宿主中建立一個單獨的LoadedPlugin對象。以下圖所示,經過這些LoadedPlugin對象,VirtualAPK就能夠管理插件並賦予插件新的意義,使其能夠像手機中安裝過的App同樣運行。

如何使用

第一步: 宿主Project的build.gradle添加

dependencies {
    classpath 'com.didi.virtualapk:gradle:0.9.8.6'
}
複製代碼

第二步:宿主的Moudle的build.gradle添加

apply plugin: 'com.didi.virtualapk.host'
implementation 'com.didi.virtualapk:core:0.9.8'
複製代碼

第三步:宿主app的Applicaiton中添加初始化:

@Override
protected void attachBaseContext(Context base) {
    super.attachBaseContext(base);
    PluginManager.getInstance(base).init();
}
複製代碼

第四步:增長混淆:

-keep class com.didi.virtualapk.internal.VAInstrumentation { *; }
-keep class com.didi.virtualapk.internal.PluginContentResolver { *; }

-dontwarn com.didi.virtualapk.**
-dontwarn android.**
-keep class android.** { *; }
複製代碼

第五步:宿主的使用:

String pluginPath = Environment.getExternalStorageDirectory().getAbsolutePath().concat("/Test.apk");
File plugin = new File(pluginPath);
PluginManager.getInstance(base).loadPlugin(plugin);

// Given "com.didi.virtualapk.demo" is the package name of plugin APK, 
// and there is an activity called `MainActivity`.
Intent intent = new Intent();
intent.setClassName("com.didi.virtualapk.demo", "com.didi.virtualapk.demo.MainActivity");
startActivity(intent);
複製代碼

第六步:插件的Project的build.gradle配置:

dependencies {
    classpath 'com.didi.virtualapk:gradle:0.9.8.6'
}
複製代碼

第七步: 插件app的build.gradle配置:

apply plugin: 'com.didi.virtualapk.plugin'
virtualApk {
    packageId = 0x6f             // The package id of Resources.
    targetHost='source/host/app' // The path of application module in host project.
    applyHostMapping = true      // [Optional] Default value is true. 
}
複製代碼

第八步:關於編譯運行命令

宿主:gradlew clean assembleRelease
插件:gradlew clean assemblePlugin
複製代碼

原理

  • 合併宿主和插件的ClassLoader 須要注意的是,插件中的類不能夠和宿主重複
  • 合併插件和宿主的資源 重設插件資源的packageId,將插件資源和宿主資源合併
  • 去除插件包對宿主的引用 構建時經過Gradle插件去除插件對宿主的代碼以及資源的引用

四大組件的實現原理

  • Activity 採用宿主manifest中佔坑的方式來繞過系統校驗,而後再加載真正的activity;
  • Service 動態代理AMS,攔截service相關的請求,將其中轉給Service Runtime去處理,Service Runtime會接管系統的全部操做;
  • Receiver 將插件中靜態註冊的receiver從新註冊一遍;
  • ContentProvider 動態代理IContentProvider,攔截provider相關的請求,將其中轉給Provider Runtime去處理,Provider Runtime會接管系統的全部操做。
    以下是VirtualAPK的總體架構圖,更詳細的內容請你們閱讀源碼。

偶編譯運行碰見的問題

  • 插件和宿主既可在同工程也可不在同工程,他們是經過targetHost來關聯的,因此很是靈活,無需擔憂結構(正常來講插件和宿主都是不一樣的工程)
  • 與阿里的熱修復框架產生了不兼容,大概跟初始化有關係(暫時剔除)
  • 與JobIntentService產生了不兼容,會報No such service componentInfo(用IntentService替代)
  • 宿主跳轉插件,發現資源界面仍是宿主的(資源id不能和宿主的資源重名)
  • 報宿主須要依賴全部com.android.support包(插件和宿主都要同時依賴com.android.support包且版本都要同樣)
  • 報IllegalStateException:You need to use a Theme.AppCompat theme,構建插件時,請使用(gradlew clean assemblePlugin)
  • 發現插件的主題未起做用(請確保宿主和插件使用同一主題)
  • 報The directory of host application doesn't exist!(targetHost路勁配置錯誤)
  • 發現騰訊X5瀏覽器加載失敗默認採起的是系統的WebView(檢查so文件,確保宿主和插件的cpu核心保持一致)

最後

關於android的插件化簡單研究大概就是醬樣子了,初次嚐鮮感受仍是蠻不錯的,可是最大的苦惱應該是業務插件該如何拆分了,基礎組件如何拆分,如何從複雜的荊棘業務中殺出一條血路,想要「萬花叢中過,片葉不沾身」,騷年,我相信你能夠的。

客官觀賞一下其餘文章

相關文章
相關標籤/搜索