ContentProvider客戶端處理provider邏輯分析
引言
前面一篇文章分析了 AMS 端處理 provider 的邏輯,請讀者務(wù)必仔細(xì)閱讀前面一篇文章,否則看本文,你可能有很多疑惑。
以查詢 provider 為例來分析客戶端是如何處理 provider,它調(diào)用的是 ContentResolver#query()
// ContentResolver.java public final @Nullable Cursor query(final @RequiresPermission.Read @NonNull Uri uri, @Nullable String[] projection, @Nullable Bundle queryArgs, @Nullable CancellationSignal cancellationSignal) { Objects.requireNonNull(uri, "uri"); // ApplicationContentResolver 的 mWrapped 為 null try { if (mWrapped != null) { return mWrapped.query(uri, projection, queryArgs, cancellationSignal); } } catch (RemoteException e) { return null; } // 1. 獲取 unstable provider IContentProvider unstableProvider = acquireUnstableProvider(uri); if (unstableProvider == null) { return null; } IContentProvider stableProvider = null; Cursor qCursor = null; try { long startTime = SystemClock.uptimeMillis(); // 獲取取消操作的接口 ICancellationSignal remoteCancellationSignal = null; if (cancellationSignal != null) { cancellationSignal.throwIfCanceled(); remoteCancellationSignal = unstableProvider.createCancellationSignal(); cancellationSignal.setRemote(remoteCancellationSignal); } try { // 2. 執(zhí)行操作 qCursor = unstableProvider.query(mContext.getAttributionSource(), uri, projection, queryArgs, remoteCancellationSignal); } catch (DeadObjectException e) { // 處理 unstable provider 進(jìn)程掛掉的情況 // 通知 AMS,provider 進(jìn)程掛掉了 unstableProviderDied(unstableProvider); // 獲取 stable provider,再次嘗試獲取數(shù)據(jù) stableProvider = acquireProvider(uri); if (stableProvider == null) { return null; } qCursor = stableProvider.query(mContext.getAttributionSource(), uri, projection, queryArgs, remoteCancellationSignal); } if (qCursor == null) { return null; } // Force query execution. Might fail and throw a runtime exception here. qCursor.getCount(); long durationMillis = SystemClock.uptimeMillis() - startTime; maybeLogQueryToEventLog(durationMillis, uri, projection, queryArgs); // 注意,這里最終還是從 stable provider 獲取 provider 接口 final IContentProvider provider = (stableProvider != null) ? stableProvider : acquireProvider(uri); final CursorWrapperInner wrapper = new CursorWrapperInner(qCursor, provider); stableProvider = null; qCursor = null; // 3. 返回?cái)?shù)據(jù) return wrapper; } catch (RemoteException e) { return null; } finally { // ... } }
縱觀整個(gè) provider 的查詢過程,其實(shí)就是三步
- 獲取 provider。
- 從獲取到的 provider 執(zhí)行查詢操作。
- 返回查詢的結(jié)果。
我們注意到,代碼中出現(xiàn)了兩種 provider,unstable provider 和 stable provider。這兩者的區(qū)別是,如果 provider 進(jìn)程掛掉了,對于 stable provider,會(huì)殺死客戶端進(jìn)程,而 unstable 不會(huì)。這個(gè)我們會(huì)在后面分析。
現(xiàn)在我們要抓住重點(diǎn),來分析如何獲取 provider 。unstable provider 和 stable provider 的獲取方式其實(shí)是一樣的,本文只分析獲取 unstbale provider。
1. 獲取 provider
對于 app 進(jìn)程來說,ContentResolver 接口的實(shí)現(xiàn)類為 ApplicationContentResolver,獲取 unstable provider 的操作最終會(huì)調(diào)用 ApplicationContentResolver#acquireUnstableProvider()
//ContextImpl.java class ContextImpl { private static final class ApplicationContentResolver extends ContentResolver { private final ActivityThread mMainThread; @Override protected IContentProvider acquireUnstableProvider(Context c, String auth) { return mMainThread.acquireProvider(c, ContentProvider.getAuthorityWithoutUserId(auth), resolveUserIdFromAuthority(auth), false); } } }
原來最終是交給 ActivityThread 來獲取 provider
// ActivityThread.java public final IContentProvider acquireProvider( Context c, String auth, int userId, boolean stable) { // 從本地獲取 final IContentProvider provider = acquireExistingProvider(c, auth, userId, stable); if (provider != null) { return provider; } ContentProviderHolder holder = null; // 合成一個(gè) KEY final ProviderKey key = getGetProviderKey(auth, userId); try { synchronized (key) { // 1. 獲取 ActivityManagerService 獲取 holder = ActivityManager.getService().getContentProvider( getApplicationThread(), c.getOpPackageName(), auth, userId, stable); // 2. 等待 provider 發(fā)布完成 // holder != null 表示 provider 存在 // holder.provider == null 表示 provider 正在發(fā)布中 // holder.mLocal 為 false,表示 provider 不是安裝在客戶端 if (holder != null && holder.provider == null && !holder.mLocal) { synchronized (key.mLock) { // 2.1 超時(shí)等等 provider 發(fā)布 // 超時(shí)時(shí)間一般為 20s key.mLock.wait(ContentResolver.CONTENT_PROVIDER_READY_TIMEOUT_MILLIS); // 這里可能因?yàn)槌瑫r(shí)被喚醒,獲取的數(shù)據(jù)為空 // 也可以是因?yàn)閜rovider發(fā)布完成,被AMS喚醒,holder 為AMS返回的數(shù)據(jù) holder = key.mHolder; } // 2.2 確認(rèn)是否是超時(shí)喚醒 if (holder != null && holder.provider == null) { // probably timed out holder = null; } } } } // ... // 這里記錄了獲取provider失敗的日志 if (holder == null) { if (UserManager.get(c).isUserUnlocked(userId)) { Slog.e(TAG, "Failed to find provider info for " + auth); } else { Slog.w(TAG, "Failed to find provider info for " + auth + " (user not unlocked)"); } return null; } // 3. 成功從服務(wù)端獲取 provider,本地安裝它 holder = installProvider(c, holder, holder.info, true /*noisy*/, holder.noReleaseNeeded, stable); return holder.provider; }
客戶端獲取 provider 的過程大致分為如下幾步
- 從 AMS 獲取 provider。
- 如果 provider 還是發(fā)布的過程中,那么就超時(shí)等待它發(fā)布完成。 但是等待是有時(shí)間限制的,大約為 20s。超時(shí)等待的過程中被喚醒,有兩種可能,一種是因?yàn)槌瑫r(shí)了,另外一種是因?yàn)?provider 成功發(fā)布,AMS 喚醒了客戶端。因此需要判斷到底是哪一種情況,檢測的條件是被喚醒后,是否獲取到 provider binder,也就是 holder.provider。詳見【1.1 等待 provider 發(fā)布】
- 從 AMS 成功獲取到 provider 后,那就在本地“安裝”。這個(gè)方法的命令起的并不是很好,如果成功從 AMS 獲取到 provider,其實(shí)這里的邏輯是保存數(shù)據(jù)。而如果 AMS 通知客戶端,provider 可以安裝在客戶端進(jìn)程中,客戶端會(huì)在這個(gè)方法中創(chuàng)建 ContentProvider 對象并保存,這才叫安裝。詳見【1.2 安裝 provider】
1.1 等待 provider 發(fā)布
從前面的文章可知,當(dāng) provider 發(fā)布超時(shí) 或者 成功發(fā)布時(shí),都會(huì)調(diào)用 ContentProviderRecord#onProviderPublishStatusLocked(boolean status) 來通知客戶端 provider 的發(fā)布狀態(tài)。參數(shù) status 如果為 true,表示發(fā)布成功,如果為 false,表示發(fā)布超時(shí)。
// ContentProviderRecord.java void onProviderPublishStatusLocked(boolean status) { final int numOfConns = connections.size(); for (int i = 0; i < numOfConns; i++) { // 遍歷所有等待 provider 發(fā)布的客戶端連接 final ContentProviderConnection conn = connections.get(i); if (conn.waiting && conn.client != null) { final ProcessRecord client = conn.client; // 記錄發(fā)布超時(shí)的日志 if (!status) { // 從這里可以看出status為false時(shí),不一定表示發(fā)布超時(shí),還可能因?yàn)檫M(jìn)程掛掉了 if (launchingApp == null) { Slog.w(TAG_AM, "Unable to launch app " + appInfo.packageName + "/" + appInfo.uid + " for provider " + info.authority + ": launching app became null"); EventLogTags.writeAmProviderLostProcess( UserHandle.getUserId(appInfo.uid), appInfo.packageName, appInfo.uid, info.authority); } else { Slog.wtf(TAG_AM, "Timeout waiting for provider " + appInfo.packageName + "/" + appInfo.uid + " for provider " + info.authority + " caller=" + client); } } // 通知客戶端 final IApplicationThread thread = client.getThread(); if (thread != null) { try { thread.notifyContentProviderPublishStatus( newHolder(status ? conn : null, false), info.authority, conn.mExpectedUserId, status); } catch (RemoteException e) { } } } conn.waiting = false; } }
很簡單,通過遍歷所有等待 provider 發(fā)布的客戶端連接,然后通過客戶端 attach 的 thread 來通知它們。
// ActivityThread.java public void notifyContentProviderPublishStatus(@NonNull ContentProviderHolder holder, @NonNull String authorities, int userId, boolean published) { final String auths[] = authorities.split(";"); for (String auth: auths) { final ProviderKey key = getGetProviderKey(auth, userId); synchronized (key.mLock) { // 保存服務(wù)端傳過來的數(shù)據(jù) key.mHolder = holder; // 喚醒等待provider的線程 key.mLock.notifyAll(); } } }
客戶端收到信息后,喚醒了等待的線程,誰在等待呢?這里是不是有點(diǎn)熟悉,其實(shí)就是前面分析獲取 provider 時(shí),超時(shí)等待,部分代碼如下
// ActivityThread.java public final IContentProvider acquireProvider( Context c, String auth, int userId, boolean stable) { // ... try { synchronized (key) { holder = ActivityManager.getService().getContentProvider( getApplicationThread(), c.getOpPackageName(), auth, userId, stable); // 等待 provider 發(fā)布完成 if (holder != null && holder.provider == null && !holder.mLocal) { synchronized (key.mLock) { // 超時(shí)等待 key.mLock.wait(ContentResolver.CONTENT_PROVIDER_READY_TIMEOUT_MILLIS); // 這里可能因?yàn)槌瑫r(shí)被喚醒,獲取的數(shù)據(jù)為空 // 也可以是因?yàn)閜rovider發(fā)布完成,被AMS喚醒,holder 為AMS返回的數(shù)據(jù) holder = key.mHolder; } // 確認(rèn)是否是超時(shí)喚醒 if (holder != null && holder.provider == null) { // probably timed out holder = null; } } } } // ... }
超時(shí)等待 provider 發(fā)布時(shí),如果一旦被喚醒,再次獲取 key.mHolder,因?yàn)槿绻晒Πl(fā)布,holder.provider 是不為空的,因?yàn)樗褪?provider binder,否則就是超時(shí)喚醒。
1.2 安裝 provider
客戶端如果成功從 AMS 獲取到 provider,那么就會(huì)安裝它,其實(shí)這里的操作是保存數(shù)據(jù),其實(shí)最主要的就是保存 provider 接口,同時(shí)也是保存 provider binder.
private ContentProviderHolder installProvider(Context context, ContentProviderHolder holder, ProviderInfo info, boolean noisy, boolean noReleaseNeeded, boolean stable) { ContentProvider localProvider = null; IContentProvider provider; // 成功從 AMS 獲取 provider,下面兩個(gè)條件都是不成立 if (holder == null || holder.provider == null) { // ... } else { // 獲取 provider 接口,其實(shí)就是獲取 provider binder provider = holder.provider; } ContentProviderHolder retHolder; synchronized (mProviderMap) { // 從 provider 接口中獲取 binder 對象 IBinder jBinder = provider.asBinder(); if (localProvider != null) { // ... } else { ProviderRefCount prc = mProviderRefCountMap.get(jBinder); if (prc != null) { // ... } else { // 1. 創(chuàng)建 provider 記錄,并保存 ProviderClientRecord client = installProviderAuthoritiesLocked( provider, localProvider, holder); // persistent app 的 provider 是不需要釋放的 if (noReleaseNeeded) { prc = new ProviderRefCount(holder, client, 1000, 1000); } else { prc = stable ? new ProviderRefCount(holder, client, 1, 0) : new ProviderRefCount(holder, client, 0, 1); } // 2. 保存 provider 計(jì)數(shù) mProviderRefCountMap.put(jBinder, prc); } retHolder = prc.holder; } } return retHolder; } private ProviderClientRecord installProviderAuthoritiesLocked(IContentProvider provider, ContentProvider localProvider, ContentProviderHolder holder) { final String auths[] = holder.info.authority.split(";"); final int userId = UserHandle.getUserId(holder.info.applicationInfo.uid); // ... // 創(chuàng)建一條 provider 記錄 final ProviderClientRecord pcr = new ProviderClientRecord( auths, provider, localProvider, holder); // 一個(gè) ContentProvider 可以聲明多個(gè) authority for (String auth : auths) { final ProviderKey key = new ProviderKey(auth, userId); // mProviderMap 保存 final ProviderClientRecord existing = mProviderMap.get(key); if (existing != null) { Slog.w(TAG, "Content provider " + pcr.mHolder.info.name + " already published as " + auth); } else { mProviderMap.put(key, pcr); } } return pcr; }
很簡單,就是用兩個(gè)數(shù)據(jù)結(jié)構(gòu)保存數(shù)據(jù)。
2. provider 實(shí)現(xiàn)多進(jìn)程實(shí)例
前面我們總是隱隱約約地提到,provider 可以安裝在客戶端進(jìn)程,那么什么樣的條件下,provider 可以安裝在客戶端進(jìn)程中? 前面一篇文章的分析中有提到過,現(xiàn)在展示出部分代碼
// ContentProviderHelper.java private ContentProviderHolder getContentProviderImpl(IApplicationThread caller, String name, IBinder token, int callingUid, String callingPackage, String callingTag, boolean stable, int userId) { // ... synchronized (mService) { // 獲取客戶端的進(jìn)程實(shí)例 ProcessRecord r = null; if (caller != null) { r = mService.getRecordForAppLOSP(caller); if (r == null) { throw new SecurityException("Unable to find app for caller " + caller + " (pid=" + Binder.getCallingPid() + ") when getting content provider " + name); } } // ... // provider 正在運(yùn)行 if (providerRunning) { cpi = cpr.info; if (r != null && cpr.canRunHere(r)) { // This provider has been published or is in the process // of being published... but it is also allowed to run // in the caller's process, so don't make a connection // and just let the caller instantiate its own instance. ContentProviderHolder holder = cpr.newHolder(null, true); // don't give caller the provider object, it needs to make its own. holder.provider = null; return holder; } // ... } // provider 沒有運(yùn)行 if (!providerRunning) { // ... if (r != null && cpr.canRunHere(r)) { // If this is a multiprocess provider, then just return its // info and allow the caller to instantiate it. Only do // this if the provider is the same user as the caller's // process, or can run as root (so can be in any process). return cpr.newHolder(null, true); } // ... } // ... } // ... }
可以看到,無論 provider 是否已經(jīng)運(yùn)行,都有機(jī)會(huì)在客戶端進(jìn)程中創(chuàng)建 provider 實(shí)例,而這個(gè)機(jī)會(huì)就在 ContentProviderRecord#canRunHere()
provider 已經(jīng)運(yùn)行,居然還可以運(yùn)行在客戶端進(jìn)程中,也就是在客戶端進(jìn)程中創(chuàng)建 ContentProvider 實(shí)例,這樣的設(shè)計(jì)又是為了什么呢?
public boolean canRunHere(ProcessRecord app) { // info 為 provider 信息,也就是在 AndroidManifest 中聲明的 provider 信息 // provider 可以 運(yùn)行在客戶端進(jìn)程中的條件 // 1. provider 所在的 app 的 uid 與客戶端 app 的 uid 相同 // 2. provider 支持多進(jìn)程 或者 provider 的進(jìn)程名與客戶端 app 的進(jìn)程名相同 return (info.multiprocess || info.processName.equals(app.processName)) && uid == app.info.uid; }
這里的條件可要看清楚了,首先 provider 所在 app 和 客戶端 app 的 uid 相同,其實(shí)就是下面這個(gè)玩意要一樣
<manifest android:sharedUserId="">
然后,還需要 provider 支持多進(jìn)程,其實(shí)就是下面這個(gè)玩意
<provider android:multiprocess="true"/>
如果 provider 不支持多進(jìn)程,只要 provider 的進(jìn)程名與客戶端 app 的進(jìn)程名一樣,provider 也是可以運(yùn)行在客戶端進(jìn)程中。那么 provider 進(jìn)程名是什么呢? provider 可以聲明自己的進(jìn)程名,如下
<provider android:process="" />
而如果 provider 沒有聲明自己的進(jìn)程名,那么 provider 進(jìn)程名取自 app 的進(jìn)程名。
現(xiàn)在 provider 怎樣運(yùn)行在客戶端進(jìn)程中,大家會(huì)玩了嗎?如果會(huì)玩了,那么繼續(xù)看下客戶端如何安裝 provider,這一次可就是真的安裝 provider 了
private ContentProviderHolder installProvider(Context context, ContentProviderHolder holder, ProviderInfo info, boolean noisy, boolean noReleaseNeeded, boolean stable) { ContentProvider localProvider = null; IContentProvider provider; // 此時(shí) holder.provider == null 是成立的 if (holder == null || holder.provider == null) { // ... try { final java.lang.ClassLoader cl = c.getClassLoader(); LoadedApk packageInfo = peekPackageInfo(ai.packageName, true); if (packageInfo == null) { // System startup case. packageInfo = getSystemContext().mPackageInfo; } // 1. 通過反射創(chuàng)建 ContentProvider 對象 localProvider = packageInfo.getAppFactory() .instantiateProvider(cl, info.name); // 獲取 provider 接口,其實(shí)就是獲取 provider binder provider = localProvider.getIContentProvider(); if (provider == null) { return null; } // 2. 為 ContentProvider 對象保存 provider 信息,并且調(diào)用 ContentProvider#onCreate() localProvider.attachInfo(c, info); } catch (java.lang.Exception e) { // ... } } else { // ... } ContentProviderHolder retHolder; synchronized (mProviderMap) { if (DEBUG_PROVIDER) Slog.v(TAG, "Checking to add " + provider + " / " + info.name); IBinder jBinder = provider.asBinder(); if (localProvider != null) { ComponentName cname = new ComponentName(info.packageName, info.name); ProviderClientRecord pr = mLocalProvidersByName.get(cname); if (pr != null) { // ... } else { // 本地創(chuàng)建 ContentProviderHolder holder = new ContentProviderHolder(info); // 保存 provider binder holder.provider = provider; // 本地安裝的 provider,不需要釋放 holder.noReleaseNeeded = true; // 3. 創(chuàng)建 provider 記錄,并保存 pr = installProviderAuthoritiesLocked(provider, localProvider, holder); mLocalProviders.put(jBinder, pr); mLocalProvidersByName.put(cname, pr); } retHolder = pr.mHolder; } else { // ... } } return retHolder; }
其實(shí)這一部分代碼在前面文章中已經(jīng)分析過,這里簡單介紹下過程
- 客戶端自己創(chuàng)建 ContentProvider 對象,然后保存 provider 信息,并調(diào)用 ContentProvider#onCreate() 方法。
- 創(chuàng)建 provider 記錄,也就是 ContentProviderRecord 對象,然后用數(shù)據(jù)結(jié)構(gòu)保存。
3. 兩種 provider 區(qū)別
前面我們提到過 unstable provider 和 stable provider 的區(qū)別,現(xiàn)在我們用代碼來解釋下這兩者的區(qū)別
假設(shè)我們通過 ActivityManager#forceStopPackage() 來殺掉 provider 進(jìn)程,在 AMS 的調(diào)用如下
public void forceStopPackage(final String packageName, int userId) { // ... try { IPackageManager pm = AppGlobals.getPackageManager(); synchronized(this) { int[] users = userId == UserHandle.USER_ALL ? mUserController.getUsers() : new int[] { userId }; for (int user : users) { // ... if (mUserController.isUserRunning(user, 0)) { // 殺掉進(jìn)程 forceStopPackageLocked(packageName, pkgUid, "from pid " + callingPid); // 發(fā)送廣播 finishForceStopPackageLocked(packageName, pkgUid); } } } } finally { Binder.restoreCallingIdentity(callingId); } }
最終調(diào)用如下代碼
final boolean forceStopPackageLocked(String packageName, int appId, boolean callerWillRestart, boolean purgeCache, boolean doit, boolean evenPersistent, boolean uninstalling, int userId, String reason) { // ... // 獲取 app 的所有 provider ArrayList<ContentProviderRecord> providers = new ArrayList<>(); if (mCpHelper.getProviderMap().collectPackageProvidersLocked(packageName, null, doit, evenPersistent, userId, providers)) { if (!doit) { return true; } didSomething = true; } // 移除 provider for (i = providers.size() - 1; i >= 0; i--) { mCpHelper.removeDyingProviderLocked(null, providers.get(i), true); } // ... }
不出意外,最終由 ContentProviderHelper 來移除 provider
boolean removeDyingProviderLocked(ProcessRecord proc, ContentProviderRecord cpr, boolean always) { // ... for (int i = cpr.connections.size() - 1; i >= 0; i--) { ContentProviderConnection conn = cpr.connections.get(i); // ... ProcessRecord capp = conn.client; final IApplicationThread thread = capp.getThread(); conn.dead = true; // 1. 如有 stable provider 的客戶端 if (conn.stableCount() > 0) { final int pid = capp.getPid(); // 注意,要排除 persistent app 進(jìn)程,以及 system_server 進(jìn)程 if (!capp.isPersistent() && thread != null && pid != 0 && pid != ActivityManagerService.MY_PID) { // 殺掉客戶端進(jìn)程 capp.killLocked( "depends on provider " + cpr.name.flattenToShortString() + " in dying proc " + (proc != null ? proc.processName : "??") + " (adj " + (proc != null ? proc.mState.getSetAdj() : "??") + ")", ApplicationExitInfo.REASON_DEPENDENCY_DIED, ApplicationExitInfo.SUBREASON_UNKNOWN, true); } } // 2. 如果只有 unstable provider 客戶端 else if (thread != null && conn.provider.provider != null) { try { // 通知客戶端移除數(shù)據(jù) thread.unstableProviderDied(conn.provider.provider.asBinder()); } catch (RemoteException e) { } // In the protocol here, we don't expect the client to correctly // clean up this connection, we'll just remove it. cpr.connections.remove(i); if (conn.client.mProviders.removeProviderConnection(conn)) { mService.stopAssociationLocked(capp.uid, capp.processName, cpr.uid, cpr.appInfo.longVersionCode, cpr.name, cpr.info.processName); } } } // ... }
看到了吧,對于 stable provider,如果 provider 進(jìn)程掛掉了,那么客戶端也會(huì)受牽連被殺掉。
而對于 unstable provider,如果 provier 進(jìn)程掛掉了,客戶端只是移除了保存了的數(shù)據(jù)而已,并不會(huì)被殺掉。
最后,我們再來看看文章開頭獲取 provider 時(shí)關(guān)于兩種 provider 代碼
// ContentResolver.java public final @Nullable Cursor query(final @RequiresPermission.Read @NonNull Uri uri, @Nullable String[] projection, @Nullable Bundle queryArgs, @Nullable CancellationSignal cancellationSignal) { // ... // 獲取 unstable provider IContentProvider unstableProvider = acquireUnstableProvider(uri); if (unstableProvider == null) { return null; } IContentProvider stableProvider = null; Cursor qCursor = null; try { // ... // 注意,這里獲取的 stable provider 并返回 final IContentProvider provider = (stableProvider != null) ? stableProvider : acquireProvider(uri); final CursorWrapperInner wrapper = new CursorWrapperInner(qCursor, provider); stableProvider = null; qCursor = null; // 返回?cái)?shù)據(jù) return wrapper; } catch (RemoteException e) { return null; } finally { // ... } }
我們可以看到,查詢的時(shí)候使用的是 unstable provier,但是返回的結(jié)果 Curosr 使用的是 stable provider。這說明了什么? 它說明了,在 Cursor 沒有被 close 之前,只要 provider 進(jìn)程掛掉了,那么客戶端也會(huì)受牽連,會(huì)被殺掉。
結(jié)束
以上就是ContentProvider客戶端處理provider邏輯分析的詳細(xì)內(nèi)容,更多關(guān)于ContentProvider客戶端provider的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android實(shí)現(xiàn)系統(tǒng)級(jí)懸浮按鈕
這篇文章主要為大家詳細(xì)介紹了Android實(shí)現(xiàn)系統(tǒng)級(jí)懸浮按鈕的相關(guān)資料,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-03-03Android?MaterialButton使用實(shí)例詳解(告別shape、selector)
我們平時(shí)寫布局,當(dāng)遇到按鈕需要圓角、或者描邊等,通常的方法是新建一個(gè)xml文件,在shape標(biāo)簽下寫,然后通過android:background或setBackground(drawable)設(shè)置,這篇文章主要給大家介紹了關(guān)于Android?MaterialButton使用詳解的相關(guān)資料,需要的朋友可以參考下2022-09-09Android系統(tǒng)自帶樣式 (android:theme)
Android系統(tǒng)中自帶樣式分享,需要的朋友可以參考下2013-01-01Android實(shí)現(xiàn)有道辭典查詢功能實(shí)例詳解
這篇文章主要介紹了Android實(shí)現(xiàn)有道辭典查詢功能的方法,結(jié)合實(shí)例形式較為詳細(xì)的分析了Android基于有道詞典查詢功能的原理與具體實(shí)現(xiàn)技巧,需要的朋友可以參考下2016-10-10Android 自定義view模板并實(shí)現(xiàn)點(diǎn)擊事件的回調(diào)
這篇文章主要介紹了Android 自定義view模板并實(shí)現(xiàn)點(diǎn)擊事件的回調(diào)的相關(guān)資料,需要的朋友可以參考下2017-01-01