提交 0ae4d69f 编写于 作者: S superq_sky

Optimized process of creating resources.

上级 7857a51c
......@@ -8,4 +8,9 @@ public class ResourcesManager {
public static ResourcesManager getInstance() {
throw new RuntimeException("Stub!");
}
public void appendLibAssetForMainAssetPath(String assetPath, String libAsset) {
throw new RuntimeException("Stub!");
}
}
\ No newline at end of file
package android.content.res;
import android.content.pm.ApplicationInfo;
import android.os.Parcel;
import android.os.Parcelable;
/**
* Created by qiaopu on 2018/5/3.
*/
public class CompatibilityInfo implements Parcelable {
public CompatibilityInfo(ApplicationInfo appInfo, int screenLayout, int sw,
boolean forceCompat) {
throw new RuntimeException("Stub!");
}
@Override
public int describeContents() {
throw new RuntimeException("Stub!");
}
@Override
public void writeToParcel(Parcel dest, int flags) {
throw new RuntimeException("Stub!");
}
public static final Parcelable.Creator<CompatibilityInfo> CREATOR
= new Parcelable.Creator<CompatibilityInfo>() {
@Override
public CompatibilityInfo createFromParcel(Parcel source) {
throw new RuntimeException("Stub!");
}
@Override
public CompatibilityInfo[] newArray(int size) {
throw new RuntimeException("Stub!");
}
};
}
package android.content.res;
import android.graphics.drawable.Drawable;
import android.util.DisplayMetrics;
/**
* Created by qiaopu on 2018/5/18.
*/
public class Resources {
public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {
throw new RuntimeException("Stub!");
}
public final AssetManager getAssets() {
throw new RuntimeException("Stub!");
}
public Configuration getConfiguration() {
throw new RuntimeException("Stub!");
}
public DisplayMetrics getDisplayMetrics() {
throw new RuntimeException("Stub!");
}
public Drawable getDrawable(int id) throws NotFoundException {
throw new RuntimeException("Stub!");
}
public CharSequence getText(int id) throws NotFoundException {
throw new RuntimeException("Stub!");
}
public XmlResourceParser getXml(int id) throws NotFoundException {
throw new RuntimeException("Stub!");
}
public ResourcesImpl getImpl() {
throw new RuntimeException("Stub!");
}
public final Theme newTheme() {
throw new RuntimeException("Stub!");
}
public final class Theme {
public void applyStyle(int resId, boolean force) {
throw new RuntimeException("Stub!");
}
public TypedArray obtainStyledAttributes(int[] attrs) {
throw new RuntimeException("Stub!");
}
public void setTo(Theme other) {
throw new RuntimeException("Stub!");
}
}
public static class NotFoundException extends RuntimeException {
}
}
package android.content.res;
/**
* Created by qiaopu on 2018/5/18.
*/
public class ResourcesImpl {
}
package android.content.res;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
/**
* Created by qiaopu on 2018/5/3.
*/
public final class ResourcesKey {
@Nullable
public final String mResDir;
@Nullable
public final String[] mSplitResDirs;
@Nullable
public final String[] mOverlayDirs;
@Nullable
public final String[] mLibDirs;
public final int mDisplayId;
@NonNull
public final Configuration mOverrideConfiguration;
@NonNull
public final CompatibilityInfo mCompatInfo;
public ResourcesKey(@Nullable String resDir,
@Nullable String[] splitResDirs,
@Nullable String[] overlayDirs,
@Nullable String[] libDirs,
int displayId,
@Nullable Configuration overrideConfig,
@Nullable CompatibilityInfo compatInfo) {
throw new RuntimeException("Stub!");
}
}
......@@ -69,7 +69,7 @@ public class PluginManager {
private Map<String, LoadedPlugin> mPlugins = new ConcurrentHashMap<>();
private final List<Callback> mCallbacks = new ArrayList<>();
private Instrumentation mInstrumentation; // Hooked instrumentation
private VAInstrumentation mInstrumentation; // Hooked instrumentation
private IActivityManager mActivityManager; // Hooked IActivityManager binder
private IContentProvider mIContentProvider; // Hooked IContentProvider binder
......@@ -277,7 +277,7 @@ public class PluginManager {
return this.mContext;
}
public Instrumentation getInstrumentation() {
public VAInstrumentation getInstrumentation() {
return this.mInstrumentation;
}
......
......@@ -47,11 +47,12 @@ import android.content.res.XmlResourceParser;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Looper;
import android.os.Process;
import android.os.UserHandle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.WorkerThread;
import android.support.annotation.UiThread;
import com.didi.virtualapk.PluginManager;
import com.didi.virtualapk.utils.DexUtil;
......@@ -108,12 +109,9 @@ public final class LoadedPlugin {
}
}
@WorkerThread
private static Resources createResources(Context context, File apk) {
private static Resources createResources(Context context, String packageName, File apk) throws Exception {
if (Constants.COMBINE_RESOURCES) {
Resources resources = ResourcesManager.createResources(context, apk.getAbsolutePath());
ResourcesManager.hookResources(context, resources);
return resources;
return ResourcesManager.createResources(context, packageName, apk);
} else {
Resources hostResources = context.getResources();
AssetManager assetManager = createAssetManager(context, apk);
......@@ -145,7 +143,11 @@ public final class LoadedPlugin {
private Application mApplication;
@UiThread
LoadedPlugin(PluginManager pluginManager, Context context, File apk) throws Exception {
if (Thread.currentThread() != Looper.getMainLooper().getThread()) {
throw new RuntimeException("plugin mast be created by UI thread.");
}
this.mPluginManager = pluginManager;
this.mHostContext = context;
this.mLocation = apk.getAbsolutePath();
......@@ -177,7 +179,7 @@ public final class LoadedPlugin {
this.mPackageManager = new PluginPackageManager();
this.mPluginContext = new PluginContext(this);
this.mNativeLibDir = context.getDir(Constants.NATIVE_DIR, Context.MODE_PRIVATE);
this.mResources = createResources(context, apk);
this.mResources = createResources(context, getPackageName(), apk);
this.mClassLoader = createClassLoader(context, apk, this.mNativeLibDir, context.getClassLoader());
tryToCopyNativeLib(apk);
......@@ -280,14 +282,13 @@ public final class LoadedPlugin {
}
public void invokeApplication() {
if (mApplication != null) {
return;
}
// make sure application's callback is run on ui thread.
RunUtil.runOnUiThread(new Runnable() {
@Override
public void run() {
if (mApplication != null) {
return;
}
mApplication = makeApplication(false, mPluginManager.getInstrumentation());
}
}, true);
......
......@@ -36,6 +36,11 @@ class PluginContext extends ContextWrapper {
super(plugin.getPluginManager().getHostContext());
this.mPlugin = plugin;
}
public PluginContext(LoadedPlugin plugin, Context base) {
super(base);
this.mPlugin = plugin;
}
@Override
public Context getApplicationContext() {
......
......@@ -16,70 +16,93 @@
package com.didi.virtualapk.internal;
import android.annotation.TargetApi;
import android.app.Activity;
import android.app.ActivityThread;
import android.app.LoadedApk;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.res.AssetManager;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.content.res.ResourcesImpl;
import android.content.res.ResourcesKey;
import android.os.Build;
import android.util.ArrayMap;
import android.util.DisplayMetrics;
import android.util.Log;
import com.didi.virtualapk.PluginManager;
import com.didi.virtualapk.utils.Reflector;
import java.io.File;
import java.lang.ref.WeakReference;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Created by renyugang on 16/8/9.
*/
class ResourcesManager {
public static final String TAG = "LoadedPlugin";
public static synchronized Resources createResources(Context hostContext, String apk) {
private static Configuration mDefaultConfiguration;
public static synchronized Resources createResources(Context hostContext, String packageName, File apk) throws Exception {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return createResourcesForN(hostContext, packageName, apk);
}
Resources resources = ResourcesManager.createResourcesSimple(hostContext, apk.getAbsolutePath());
ResourcesManager.hookResources(hostContext, resources);
return resources;
}
private static synchronized Resources createResourcesSimple(Context hostContext, String apk) throws Exception {
Resources hostResources = hostContext.getResources();
Resources newResources = null;
AssetManager assetManager;
try {
Reflector reflector = Reflector.on(AssetManager.class).method("addAssetPath", String.class);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
assetManager = AssetManager.class.newInstance();
reflector.bind(assetManager);
reflector.call(hostContext.getApplicationInfo().sourceDir);
} else {
assetManager = hostResources.getAssets();
reflector.bind(assetManager);
}
reflector.call(apk);
List<LoadedPlugin> pluginList = PluginManager.getInstance(hostContext).getAllLoadedPlugins();
for (LoadedPlugin plugin : pluginList) {
reflector.call(plugin.getLocation());
}
if (isMiUi(hostResources)) {
newResources = MiUiResourcesCompat.createResources(hostResources, assetManager);
} else if (isVivo(hostResources)) {
newResources = VivoResourcesCompat.createResources(hostContext, hostResources, assetManager);
} else if (isNubia(hostResources)) {
newResources = NubiaResourcesCompat.createResources(hostResources, assetManager);
} else if (isNotRawResources(hostResources)) {
newResources = AdaptationResourcesCompat.createResources(hostResources, assetManager);
} else {
// is raw android resources
newResources = new Resources(assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration());
}
// lastly, sync all LoadedPlugin to newResources
for (LoadedPlugin plugin : pluginList) {
plugin.updateResources(newResources);
}
} catch (Exception e) {
e.printStackTrace();
Reflector reflector = Reflector.on(AssetManager.class).method("addAssetPath", String.class);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
assetManager = AssetManager.class.newInstance();
reflector.bind(assetManager);
reflector.call(hostContext.getApplicationInfo().sourceDir);
} else {
assetManager = hostResources.getAssets();
reflector.bind(assetManager);
}
reflector.call(apk);
List<LoadedPlugin> pluginList = PluginManager.getInstance(hostContext).getAllLoadedPlugins();
for (LoadedPlugin plugin : pluginList) {
reflector.call(plugin.getLocation());
}
if (isMiUi(hostResources)) {
newResources = MiUiResourcesCompat.createResources(hostResources, assetManager);
} else if (isVivo(hostResources)) {
newResources = VivoResourcesCompat.createResources(hostContext, hostResources, assetManager);
} else if (isNubia(hostResources)) {
newResources = NubiaResourcesCompat.createResources(hostResources, assetManager);
} else if (isNotRawResources(hostResources)) {
newResources = AdaptationResourcesCompat.createResources(hostResources, assetManager);
} else {
// is raw android resources
newResources = new Resources(assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration());
}
// lastly, sync all LoadedPlugin to newResources
for (LoadedPlugin plugin : pluginList) {
plugin.updateResources(newResources);
}
return newResources;
}
public static void hookResources(Context base, Resources resources) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return;
}
try {
Reflector reflector = Reflector.with(base);
reflector.field("mResources").set(resources);
......@@ -108,6 +131,90 @@ class ResourcesManager {
e.printStackTrace();
}
}
/**
* Use System Apis to update all existing resources.
* <br/>
* 1. Update ApplicationInfo.splitSourceDirs and LoadedApk.mSplitResDirs
* <br/>
* 2. Replace all keys of ResourcesManager.mResourceImpls to new ResourcesKey
* <br/>
* 3. Use ResourcesManager.appendLibAssetForMainAssetPath(appInfo.publicSourceDir, "${packageName}.vastub") to update all existing resources.
* <br/>
*
* see android.webkit.WebViewDelegate.addWebViewAssetPath(Context)
*/
@TargetApi(Build.VERSION_CODES.N)
private static Resources createResourcesForN(Context context, String packageName, File apk) throws Exception {
long startTime = System.currentTimeMillis();
String newAssetPath = apk.getAbsolutePath();
ApplicationInfo info = context.getApplicationInfo();
String baseResDir = info.publicSourceDir;
info.splitSourceDirs = append(info.splitSourceDirs, newAssetPath);
LoadedApk loadedApk = Reflector.with(context).field("mPackageInfo").get();
Reflector rLoadedApk = Reflector.with(loadedApk).field("mSplitResDirs");
String[] splitResDirs = rLoadedApk.get();
rLoadedApk.set(append(splitResDirs, newAssetPath));
final android.app.ResourcesManager resourcesManager = android.app.ResourcesManager.getInstance();
ArrayMap<ResourcesKey, WeakReference<ResourcesImpl>> originalMap = Reflector.with(resourcesManager).field("mResourceImpls").get();
synchronized (resourcesManager) {
HashMap<ResourcesKey, WeakReference<ResourcesImpl>> resolvedMap = new HashMap<>();
if (Build.VERSION.SDK_INT >= 28
|| (Build.VERSION.SDK_INT == 27 && Build.VERSION.PREVIEW_SDK_INT != 0)) { // P Preview
ResourcesManagerCompatForP.resolveResourcesImplMap(originalMap, resolvedMap, context, loadedApk);
} else {
ResourcesManagerCompatForN.resolveResourcesImplMap(originalMap, resolvedMap, baseResDir, newAssetPath);
}
originalMap.clear();
originalMap.putAll(resolvedMap);
}
android.app.ResourcesManager.getInstance().appendLibAssetForMainAssetPath(baseResDir, packageName + ".vastub");
Resources newResources = context.getResources();
// lastly, sync all LoadedPlugin to newResources
for (LoadedPlugin plugin : PluginManager.getInstance(context).getAllLoadedPlugins()) {
plugin.updateResources(newResources);
}
Log.d(TAG, "createResourcesForN cost time: +" + (System.currentTimeMillis() - startTime) + "ms");
return newResources;
}
private static String[] append(String[] paths, String newPath) {
if (contains(paths, newPath)) {
return paths;
}
final int newPathsCount = 1 + (paths != null ? paths.length : 0);
final String[] newPaths = new String[newPathsCount];
if (paths != null) {
System.arraycopy(paths, 0, newPaths, 0, paths.length);
}
newPaths[newPathsCount - 1] = newPath;
return newPaths;
}
@TargetApi(Build.VERSION_CODES.KITKAT)
private static boolean contains(String[] array, String value) {
if (array == null) {
return false;
}
for (int i = 0; i < array.length; i++) {
if (Objects.equals(array[i], value)) {
return true;
}
}
return false;
}
private static boolean isMiUi(Resources resources) {
return resources.getClass().getName().equals("android.content.res.MiuiResources");
......@@ -170,4 +277,69 @@ class ResourcesManager {
}
}
private static final class ResourcesManagerCompatForN {
public static void resolveResourcesImplMap(Map<ResourcesKey, WeakReference<ResourcesImpl>> originalMap, Map<ResourcesKey, WeakReference<ResourcesImpl>> resolvedMap, String baseResDir, String newAssetPath) throws Exception {
for (Map.Entry<ResourcesKey, WeakReference<ResourcesImpl>> entry : originalMap.entrySet()) {
ResourcesKey key = entry.getKey();
if (Objects.equals(key.mResDir, baseResDir)) {
resolvedMap.put(new ResourcesKey(key.mResDir,
append(key.mSplitResDirs, newAssetPath),
key.mOverlayDirs,
key.mLibDirs,
key.mDisplayId,
key.mOverrideConfiguration,
key.mCompatInfo), entry.getValue());
} else {
resolvedMap.put(key, entry.getValue());
}
}
}
}
private static final class ResourcesManagerCompatForP {
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
public static void resolveResourcesImplMap(Map<ResourcesKey, WeakReference<ResourcesImpl>> originalMap, Map<ResourcesKey, WeakReference<ResourcesImpl>> resolvedMap, Context context, LoadedApk loadedApk) throws Exception {
HashMap<ResourcesImpl, Context> newResImplMap = new HashMap<>();
Map<ResourcesImpl, ResourcesKey> resKeyMap = new HashMap<>();
Resources newRes;
// Recreate the resImpl of the context
// See LoadedApk.getResources()
if (mDefaultConfiguration == null) {
mDefaultConfiguration = new Configuration();
}
newRes = context.createConfigurationContext(mDefaultConfiguration).getResources();
newResImplMap.put(newRes.getImpl(), context);
// Recreate the ResImpl of the activity
for (WeakReference<Activity> ref : PluginManager.getInstance(context).getInstrumentation().getActivities()) {
Activity activity = ref.get();
if (activity != null) {
newRes = activity.createConfigurationContext(activity.getResources().getConfiguration()).getResources();
newResImplMap.put(newRes.getImpl(), activity);
}
}
// Mapping all resKey and resImpl
for (Map.Entry<ResourcesKey, WeakReference<ResourcesImpl>> entry : originalMap.entrySet()) {
ResourcesImpl resImpl = entry.getValue().get();
if (resImpl != null) {
resKeyMap.put(resImpl, entry.getKey());
}
resolvedMap.put(entry.getKey(), entry.getValue());
}
// Replace the resImpl to the new resKey and remove the origin resKey
for (Map.Entry<ResourcesImpl, Context> entry : newResImplMap.entrySet()) {
ResourcesKey newKey = resKeyMap.get(entry.getKey());
ResourcesImpl originResImpl = entry.getValue().getResources().getImpl();
resolvedMap.put(newKey, new WeakReference<>(originResImpl));
resolvedMap.remove(resKeyMap.get(originResImpl));
}
}
}
}
......@@ -37,6 +37,10 @@ import com.didi.virtualapk.PluginManager;
import com.didi.virtualapk.utils.PluginUtil;
import com.didi.virtualapk.utils.Reflector;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
/**
* Created by renyugang on 16/8/10.
......@@ -46,6 +50,8 @@ public class VAInstrumentation extends Instrumentation implements Handler.Callba
public static final int LAUNCH_ACTIVITY = 100;
private Instrumentation mBase;
private final ArrayList<WeakReference<Activity>> mActivities = new ArrayList<>();
PluginManager mPluginManager;
......@@ -98,7 +104,7 @@ public class VAInstrumentation extends Instrumentation implements Handler.Callba
ComponentName component = PluginUtil.getComponent(intent);
if (component == null) {
return mBase.newActivity(cl, className, intent);
return newActivity(mBase.newActivity(cl, className, intent));
}
String targetClassName = component.getClassName();
......@@ -107,7 +113,7 @@ public class VAInstrumentation extends Instrumentation implements Handler.Callba
LoadedPlugin plugin = this.mPluginManager.getLoadedPlugin(component);
if (plugin == null) {
return mBase.newActivity(cl, className, intent);
return newActivity(mBase.newActivity(cl, className, intent));
}
Activity activity = mBase.newActivity(plugin.getClassLoader(), targetClassName, intent);
......@@ -120,10 +126,10 @@ public class VAInstrumentation extends Instrumentation implements Handler.Callba
// ignored.
}
return activity;
return newActivity(activity);
}
return mBase.newActivity(cl, className, intent);
return newActivity(mBase.newActivity(cl, className, intent));
}
@Override
......@@ -152,7 +158,7 @@ public class VAInstrumentation extends Instrumentation implements Handler.Callba
LoadedPlugin plugin = this.mPluginManager.getLoadedPlugin(intent);
Reflector.with(base).field("mResources").set(plugin.getResources());
Reflector reflector = Reflector.with(activity);
reflector.field("mBase").set(plugin.getPluginContext());
reflector.field("mBase").set(new PluginContext(plugin, activity.getBaseContext()));
reflector.field("mApplication").set(plugin.getApplication());
// set screenOrientation
......@@ -207,4 +213,21 @@ public class VAInstrumentation extends Instrumentation implements Handler.Callba
return mBase.getComponentName();
}
private Activity newActivity(Activity activity) {
synchronized (mActivities) {
for (int i = mActivities.size() - 1; i >= 0; i--) {
if (mActivities.get(i).get() == null) {
mActivities.remove(i);
}
}
mActivities.add(new WeakReference<>(activity));
}
return activity;
}
List<WeakReference<Activity>> getActivities() {
synchronized (mActivities) {
return new ArrayList<>(mActivities);
}
}
}
......@@ -14,15 +14,18 @@ import android.support.annotation.NonNull;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import com.didi.virtualapk.internal.PluginContentResolver;
import com.didi.virtualapk.internal.LoadedPlugin;
import com.didi.virtualapk.internal.PluginContentResolver;
import java.io.File;
public class MainActivity extends AppCompatActivity {
private static final int PERMISSION_REQUEST_CODE_STORAGE = 20171222;
......@@ -105,14 +108,22 @@ public class MainActivity extends AppCompatActivity {
bookUri = PluginContentResolver.wrapperUri(plugin, bookUri);
Cursor bookCursor = getContentResolver().query(bookUri, new String[]{"_id", "name"}, null, null, null);
while (bookCursor.moveToNext()) {
int bookId = bookCursor.getInt(0);
String bookName = bookCursor.getString(1);
Log.d("ryg", "query book:" + bookId + ", " + bookName);
if (bookCursor != null) {
while (bookCursor.moveToNext()) {
int bookId = bookCursor.getInt(0);
String bookName = bookCursor.getString(1);
Log.d("ryg", "query book:" + bookId + ", " + bookName);
}
bookCursor.close();
}
bookCursor.close();
} else if (v.getId() == R.id.about) {
showAbout();
} else if (v.getId() == R.id.webview) {
LinearLayout linearLayout = (LinearLayout) v.getParent();
WebView webView = new WebView(this);
linearLayout.addView(webView, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
webView.setWebViewClient(new WebViewClient());
webView.loadUrl("http://github.com/didi/VirtualAPK");
}
}
......
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
......@@ -10,30 +11,32 @@
tools:context="com.didi.virtualapk.MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Hello World!"
android:id="@+id/textView" />
<Button
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/open_plugin"
android:id="@+id/button"
android:onClick="onButtonClick"
android:layout_below="@+id/textView"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:layout_marginTop="42dp" />
android:layout_marginTop="10dp" />
<Button
android:text="@string/about"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/button"
android:onClick="onButtonClick"
android:layout_alignParentLeft="true"
android:layout_marginTop="58dp"
android:id="@+id/about"
android:layout_alignRight="@+id/button" />
</RelativeLayout>
android:layout_marginTop="10dp"
android:id="@+id/about" />
<Button
android:text="@string/webview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="onButtonClick"
android:layout_marginTop="10dp"
android:id="@+id/webview" />
</LinearLayout>
......@@ -2,5 +2,6 @@
<string name="app_name">VirtualAPK-EN</string>
<string name="open_plugin">open plugin</string>
<string name="about">about</string>
<string name="webview">Append WebView</string>
<string name="about_detail">VirtualAPK is a plugin framework powered by DiDi company for Android,see the source code : https://github.com/didi/VirtualAPK</string>
</resources>
\ No newline at end of file
......@@ -2,5 +2,6 @@
<string name="app_name">VirtualAPK</string>
<string name="open_plugin">加载插件</string>
<string name="about">关于</string>
<string name="webview">Append WebView</string>
<string name="about_detail">VirtualAPK 是一款由滴滴出行研发的 Android 插件化框架,项目地址:https://github.com/didi/VirtualAPK</string>
</resources>
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册