Android插件化(一):使用改进的MultiDex动态加载assets中的apk

标签: android 插件 multidex | 发表时间:2015-12-27 10:41 | 作者:NUPTboyZHB
分享到:
出处:http://blog.csdn.net

Android插件化(一):使用改进的MultiDex动态加载assets中的apk

简介

为了解决65535方法数超标的问题,Google推荐使用MultiDex来加载classes2.dex,classes3.dex等等,其基本思想就是在运行时动态修改ClassLoader,以达到动态加载类的目的。为了更好的理解MultiDex的工作原理,可以先看一下ClassLoader的工作原理[1].然后参见PathClassLoader的源码,当然,它继承自BaseDexClassLoader,主要源码都在BaseDexClassLoader中。MultiDex加载离线apk的过程如下:
MultiDex

我们可以在Application的onCreate方法或者Activity的attachBaseContext方法中开发加载。

动态加载assets中的apk

根据MultiDex的源码,我们可以修改其install方法,然后从assets资源中解压出所需要加载的apk文件,然后调用installSecondaryDexes方法,将其加载到当前Application的ClassLoader当中,这样,在运行的时候,就可以通过当前的ClassLoader查找到离线apk中的类了。

[AssetsMultiDexLoader.class]

  package net.mobctrl.hostapk;

import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Set;
import java.util.zip.ZipFile;

import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.os.Build;
import android.util.Log;
import dalvik.system.DexFile;

/**
 * @Author Zheng Haibo
 * @Mail mochuan.zhb@alibaba-inc.com
 * @Company Alibaba Group
 * @PersonalWebsite http://www.mobctrl.net
 * @version $Id: AssetsDex.java, v 0.1 2015年12月10日 下午5:36:23 mochuan.zhb Exp $
 * @Description ClassLoader
 */
public class AssetsMultiDexLoader {

    private static final String TAG = "AssetsApkLoader";

    private static boolean installed = false;

    private static final int MAX_SUPPORTED_SDK_VERSION = 20;

    private static final int MIN_SDK_VERSION = 4;

    private static final Set<String> installedApk = new HashSet<String>();

    private AssetsMultiDexLoader() {

    }

    /**
     * 安装Assets中的apk文件
     * 
     * @param context
     */
    public static void install(Context context) {
        Log.i(TAG, "install...");
        if (installed) {
            return;
        }
        try {
            clearOldDexDir(context);
        } catch (Throwable t) {
            Log.w(TAG,
                    "Something went wrong when trying to clear old MultiDex extraction, "
                            + "continuing without cleaning.", t);
        }
        AssetsManager.copyAllAssetsApk(context);
        Log.i(TAG, "install");
        if (Build.VERSION.SDK_INT < MIN_SDK_VERSION) {
            throw new RuntimeException("Multi dex installation failed. SDK "
                    + Build.VERSION.SDK_INT
                    + " is unsupported. Min SDK version is " + MIN_SDK_VERSION
                    + ".");
        }
        try {
            ApplicationInfo applicationInfo = getApplicationInfo(context);
            if (applicationInfo == null) {
                // Looks like running on a test Context, so just return without
                // patching.
                return;
            }
            synchronized (installedApk) {
                String apkPath = applicationInfo.sourceDir;
                if (installedApk.contains(apkPath)) {
                    return;
                }
                installedApk.add(apkPath);
                if (Build.VERSION.SDK_INT > MAX_SUPPORTED_SDK_VERSION) {
                    Log.w(TAG,
                            "MultiDex is not guaranteed to work in SDK version "
                                    + Build.VERSION.SDK_INT
                                    + ": SDK version higher than "
                                    + MAX_SUPPORTED_SDK_VERSION
                                    + " should be backed by "
                                    + "runtime with built-in multidex capabilty but it's not the "
                                    + "case here: java.vm.version=\""
                                    + System.getProperty("java.vm.version")
                                    + "\"");
                }
                /*
                 * The patched class loader is expected to be a descendant of
                 * dalvik.system.BaseDexClassLoader. We modify its
                 * dalvik.system.DexPathList pathList field to append additional
                 * DEX file entries.
                 */
                ClassLoader loader;
                try {
                    loader = context.getClassLoader();
                } catch (RuntimeException e) {
                    /*
                     * Ignore those exceptions so that we don't break tests
                     * relying on Context like a android.test.mock.MockContext
                     * or a android.content.ContextWrapper with a null base
                     * Context.
                     */
                    Log.w(TAG,
                            "Failure while trying to obtain Context class loader. "
                                    + "Must be running in test mode. Skip patching.",
                            e);
                    return;
                }
                if (loader == null) {
                    // Note, the context class loader is null when running
                    // Robolectric tests.
                    Log.e(TAG,
                            "Context class loader is null. Must be running in test mode. "
                                    + "Skip patching.");
                    return;
                }
                // 获取dex文件列表
                File dexDir = context.getDir(AssetsManager.APK_DIR, Context.MODE_PRIVATE);
                File[] szFiles = dexDir.listFiles(new FilenameFilter() {

                    @Override
                    public boolean accept(File dir, String filename) {
                        return filename.endsWith(AssetsManager.FILE_FILTER);
                    }
                });
                List<File> files = new ArrayList<File>();
                for (File f : szFiles) {
                    Log.i(TAG, "load file:" + f.getName());
                    files.add(f);
                }
                Log.i(TAG, "loader before:" + context.getClassLoader());
                installSecondaryDexes(loader, dexDir, files);
                Log.i(TAG, "loader end:" + context.getClassLoader());
            }
        } catch (Exception e) {
            Log.e(TAG, "Multidex installation failure", e);
            throw new RuntimeException("Multi dex installation failed ("
                    + e.getMessage() + ").");
        }
        installed = true;
        Log.i(TAG, "install done");
    }

    private static ApplicationInfo getApplicationInfo(Context context)
            throws NameNotFoundException {
        PackageManager pm;
        String packageName;
        try {
            pm = context.getPackageManager();
            packageName = context.getPackageName();
        } catch (RuntimeException e) {
            /*
             * Ignore those exceptions so that we don't break tests relying on
             * Context like a android.test.mock.MockContext or a
             * android.content.ContextWrapper with a null base Context.
             */
            Log.w(TAG,
                    "Failure while trying to obtain ApplicationInfo from Context. "
                            + "Must be running in test mode. Skip patching.", e);
            return null;
        }
        if (pm == null || packageName == null) {
            // This is most likely a mock context, so just return without
            // patching.
            return null;
        }
        ApplicationInfo applicationInfo = pm.getApplicationInfo(packageName,
                PackageManager.GET_META_DATA);
        return applicationInfo;
    }

    private static void installSecondaryDexes(ClassLoader loader, File dexDir,
            List<File> files) throws IllegalArgumentException,
            IllegalAccessException, NoSuchFieldException,
            InvocationTargetException, NoSuchMethodException, IOException {
        if (!files.isEmpty()) {
            if (Build.VERSION.SDK_INT >= 19) {
                V19.install(loader, files, dexDir);
            } else if (Build.VERSION.SDK_INT >= 14) {
                V14.install(loader, files, dexDir);
            } else {
                V4.install(loader, files);
            }
        }
    }

    /**
     * Locates a given field anywhere in the class inheritance hierarchy.
     *
     * @param instance
     *            an object to search the field into.
     * @param name
     *            field name
     * @return a field object
     * @throws NoSuchFieldException
     *             if the field cannot be located
     */
    private static Field findField(Object instance, String name)
            throws NoSuchFieldException {
        for (Class<?> clazz = instance.getClass(); clazz != null; clazz = clazz
                .getSuperclass()) {
            try {
                Field field = clazz.getDeclaredField(name);

                if (!field.isAccessible()) {
                    field.setAccessible(true);
                }

                return field;
            } catch (NoSuchFieldException e) {
                // ignore and search next
            }
        }

        throw new NoSuchFieldException("Field " + name + " not found in "
                + instance.getClass());
    }

    /**
     * Locates a given method anywhere in the class inheritance hierarchy.
     *
     * @param instance
     *            an object to search the method into.
     * @param name
     *            method name
     * @param parameterTypes
     *            method parameter types
     * @return a method object
     * @throws NoSuchMethodException
     *             if the method cannot be located
     */
    private static Method findMethod(Object instance, String name,
            Class<?>... parameterTypes) throws NoSuchMethodException {
        for (Class<?> clazz = instance.getClass(); clazz != null; clazz = clazz
                .getSuperclass()) {
            try {
                Method method = clazz.getDeclaredMethod(name, parameterTypes);

                if (!method.isAccessible()) {
                    method.setAccessible(true);
                }

                return method;
            } catch (NoSuchMethodException e) {
                // ignore and search next
            }
        }

        throw new NoSuchMethodException("Method " + name + " with parameters "
                + Arrays.asList(parameterTypes) + " not found in "
                + instance.getClass());
    }

    /**
     * Replace the value of a field containing a non null array, by a new array
     * containing the elements of the original array plus the elements of
     * extraElements.
     * 
     * @param instance
     *            the instance whose field is to be modified.
     * @param fieldName
     *            the field to modify.
     * @param extraElements
     *            elements to append at the end of the array.
     */
    private static void expandFieldArray(Object instance, String fieldName,
            Object[] extraElements) throws NoSuchFieldException,
            IllegalArgumentException, IllegalAccessException {
        Field jlrField = findField(instance, fieldName);
        Object[] original = (Object[]) jlrField.get(instance);
        Object[] combined = (Object[]) Array.newInstance(original.getClass()
                .getComponentType(), original.length + extraElements.length);
        System.arraycopy(original, 0, combined, 0, original.length);
        System.arraycopy(extraElements, 0, combined, original.length,
                extraElements.length);
        jlrField.set(instance, combined);
    }

    private static void clearOldDexDir(Context context) throws Exception {
        File dexDir = context.getDir(AssetsManager.APK_DIR, Context.MODE_PRIVATE);
        if (dexDir.isDirectory()) {
            Log.i(TAG, "Clearing old secondary dex dir (" + dexDir.getPath()
                    + ").");
            File[] files = dexDir.listFiles();
            if (files == null) {
                Log.w(TAG, "Failed to list secondary dex dir content ("
                        + dexDir.getPath() + ").");
                return;
            }
            for (File oldFile : files) {
                Log.i(TAG, "Trying to delete old file " + oldFile.getPath()
                        + " of size " + oldFile.length());
                if (!oldFile.delete()) {
                    Log.w(TAG, "Failed to delete old file " + oldFile.getPath());
                } else {
                    Log.i(TAG, "Deleted old file " + oldFile.getPath());
                }
            }
            if (!dexDir.delete()) {
                Log.w(TAG,
                        "Failed to delete secondary dex dir "
                                + dexDir.getPath());
            } else {
                Log.i(TAG, "Deleted old secondary dex dir " + dexDir.getPath());
            }
        }
    }

    /**
     * Installer for platform versions 19.
     */
    private static final class V19 {

        private static void install(ClassLoader loader,
                List<File> additionalClassPathEntries, File optimizedDirectory)
                throws IllegalArgumentException, IllegalAccessException,
                NoSuchFieldException, InvocationTargetException,
                NoSuchMethodException {
            /*
             * The patched class loader is expected to be a descendant of
             * dalvik.system.BaseDexClassLoader. We modify its
             * dalvik.system.DexPathList pathList field to append additional DEX
             * file entries.
             */
            Field pathListField = findField(loader, "pathList");
            Object dexPathList = pathListField.get(loader);
            ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
            expandFieldArray(
                    dexPathList,
                    "dexElements",
                    makeDexElements(dexPathList, new ArrayList<File>(
                            additionalClassPathEntries), optimizedDirectory,
                            suppressedExceptions));
            if (suppressedExceptions.size() > 0) {
                for (IOException e : suppressedExceptions) {
                    Log.w(TAG, "Exception in makeDexElement", e);
                }
                Field suppressedExceptionsField = findField(loader,
                        "dexElementsSuppressedExceptions");
                IOException[] dexElementsSuppressedExceptions = (IOException[]) suppressedExceptionsField
                        .get(loader);

                if (dexElementsSuppressedExceptions == null) {
                    dexElementsSuppressedExceptions = suppressedExceptions
                            .toArray(new IOException[suppressedExceptions
                                    .size()]);
                } else {
                    IOException[] combined = new IOException[suppressedExceptions
                            .size() + dexElementsSuppressedExceptions.length];
                    suppressedExceptions.toArray(combined);
                    System.arraycopy(dexElementsSuppressedExceptions, 0,
                            combined, suppressedExceptions.size(),
                            dexElementsSuppressedExceptions.length);
                    dexElementsSuppressedExceptions = combined;
                }

                suppressedExceptionsField.set(loader,
                        dexElementsSuppressedExceptions);
            }
        }

        /**
         * A wrapper around
         * {@code private static final dalvik.system.DexPathList#makeDexElements}
         * .
         */
        private static Object[] makeDexElements(Object dexPathList,
                ArrayList<File> files, File optimizedDirectory,
                ArrayList<IOException> suppressedExceptions)
                throws IllegalAccessException, InvocationTargetException,
                NoSuchMethodException {
            Method makeDexElements = findMethod(dexPathList, "makeDexElements",
                    ArrayList.class, File.class, ArrayList.class);

            return (Object[]) makeDexElements.invoke(dexPathList, files,
                    optimizedDirectory, suppressedExceptions);
        }
    }

    /**
     * Installer for platform versions 14, 15, 16, 17 and 18.
     */
    private static final class V14 {

        private static void install(ClassLoader loader,
                List<File> additionalClassPathEntries, File optimizedDirectory)
                throws IllegalArgumentException, IllegalAccessException,
                NoSuchFieldException, InvocationTargetException,
                NoSuchMethodException {
            /*
             * The patched class loader is expected to be a descendant of
             * dalvik.system.BaseDexClassLoader. We modify its
             * dalvik.system.DexPathList pathList field to append additional DEX
             * file entries.
             */
            Field pathListField = findField(loader, "pathList");
            Object dexPathList = pathListField.get(loader);
            expandFieldArray(
                    dexPathList,
                    "dexElements",
                    makeDexElements(dexPathList, new ArrayList<File>(
                            additionalClassPathEntries), optimizedDirectory));
        }

        /**
         * A wrapper around
         * {@code private static final dalvik.system.DexPathList#makeDexElements}
         * .
         */
        private static Object[] makeDexElements(Object dexPathList,
                ArrayList<File> files, File optimizedDirectory)
                throws IllegalAccessException, InvocationTargetException,
                NoSuchMethodException {
            Method makeDexElements = findMethod(dexPathList, "makeDexElements",
                    ArrayList.class, File.class);

            return (Object[]) makeDexElements.invoke(dexPathList, files,
                    optimizedDirectory);
        }
    }

    /**
     * Installer for platform versions 4 to 13.
     */
    private static final class V4 {
        private static void install(ClassLoader loader,
                List<File> additionalClassPathEntries)
                throws IllegalArgumentException, IllegalAccessException,
                NoSuchFieldException, IOException {
            /*
             * The patched class loader is expected to be a descendant of
             * dalvik.system.DexClassLoader. We modify its fields mPaths,
             * mFiles, mZips and mDexs to append additional DEX file entries.
             */
            int extraSize = additionalClassPathEntries.size();

            Field pathField = findField(loader, "path");

            StringBuilder path = new StringBuilder(
                    (String) pathField.get(loader));
            String[] extraPaths = new String[extraSize];
            File[] extraFiles = new File[extraSize];
            ZipFile[] extraZips = new ZipFile[extraSize];
            DexFile[] extraDexs = new DexFile[extraSize];
            for (ListIterator<File> iterator = additionalClassPathEntries
                    .listIterator(); iterator.hasNext();) {
                File additionalEntry = iterator.next();
                String entryPath = additionalEntry.getAbsolutePath();
                path.append(':').append(entryPath);
                int index = iterator.previousIndex();
                extraPaths[index] = entryPath;
                extraFiles[index] = additionalEntry;
                extraZips[index] = new ZipFile(additionalEntry);
                extraDexs[index] = DexFile.loadDex(entryPath, entryPath
                        + ".dex", 0);
            }

            pathField.set(loader, path.toString());
            expandFieldArray(loader, "mPaths", extraPaths);
            expandFieldArray(loader, "mFiles", extraFiles);
            expandFieldArray(loader, "mZips", extraZips);
            expandFieldArray(loader, "mDexs", extraDexs);
        }
    }

}

[AssetsManager.java]

  package net.mobctrl.hostapk;

import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;

import android.content.Context;
import android.content.res.AssetManager;
import android.util.Log;

/**
 * @Author Zheng Haibo
 * @Mail mochuan.zhb@alibaba-inc.com
 * @Company Alibaba Group
 * @PersonalWebsite http://www.mobctrl.net
 * @version $Id: AssetsManager.java, v 0.1 2015年12月11日 下午4:41:10 mochuan.zhb Exp $
 * @Description
 */
public class AssetsManager {

    public static final String TAG = "AssetsApkLoader";

    //从assets复制出去的apk的目标目录
    public static final String APK_DIR = "third_apk";

    //文件结尾过滤
    public static final String FILE_FILTER = ".apk";


    /**
     * 将资源文件中的apk文件拷贝到私有目录中
     * 
     * @param context
     */
    public static void copyAllAssetsApk(Context context) {

        AssetManager assetManager = context.getAssets();
        long startTime = System.currentTimeMillis();
        try {
            File dex = context.getDir(APK_DIR, Context.MODE_PRIVATE);
            dex.mkdir();
            String []fileNames = assetManager.list("");
            for(String fileName:fileNames){
                if(!fileName.endsWith(FILE_FILTER)){
                    return;
                }
                InputStream in = null;
                OutputStream out = null;
                in = assetManager.open(fileName);
                File f = new File(dex, fileName);
                if (f.exists() && f.length() == in.available()) {
                    Log.i(TAG, fileName+"no change");
                    return;
                }
                Log.i(TAG, fileName+" chaneged");
                out = new FileOutputStream(f);
                byte[] buffer = new byte[2048];
                int read;
                while ((read = in.read(buffer)) != -1) {
                    out.write(buffer, 0, read);
                }
                in.close();
                in = null;
                out.flush();
                out.close();
                out = null;
                Log.i(TAG, fileName+" copy over");
            }
            Log.i(TAG,"###copyAssets time = "+(System.currentTimeMillis() - startTime));
        } catch (Exception e) {
            e.printStackTrace();
        }

    }
}

以上就是从assets中加载apk的核心代码。

DEMO运行

打开BundleApk项目,编译成apk。然后将BundleApk.apk文件拷贝到HostApk项目的assets目录,在HostApk的MainActivity方法的onCreate当中,调用AssetsMultiDexLoader.install(getApplicationContext());加载BundleApk.apk。然后我们就可以通过如下两种方式调用BundleApk中的类:

  • Class.forName
    由于我们的HostApk没有BundleApk类的引用,所以我们需要用反射的方式调用。
  private void loadClass(){
        try{
            Class<?> clazz = Class.forName("net.mobctrl.normal.apk.FileUtils");

            Constructor<?> constructor = clazz.getConstructor();
            Object bundleUtils = constructor.newInstance();

            Method printSumMethod = clazz.getMethod("print", Context.class,String.class);
            printSumMethod.setAccessible(true);
            printSumMethod.invoke(bundleUtils,
                    getApplicationContext(), "Hello");
        }catch(Exception e){
            e.printStackTrace();
        }

    }
  • loadClass
    我们也可以直接获取当前的ClassLoader对象,然后调用其loadClass方法
  @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
    private void loadApk() {
        try {
            Class<?> clazz = getClassLoader()
                    .loadClass("net.mobctrl.normal.apk.Utils");
            Constructor<?> constructor = clazz.getConstructor();
            Object bundleUtils = constructor.newInstance();

            Method printSumMethod = clazz.getMethod("printSum", Context.class,
                    int.class, int.class, String.class);
            printSumMethod.setAccessible(true);
            Integer sum = (Integer)printSumMethod.invoke(bundleUtils,
                    getApplicationContext(), 10, 20, "计算结果");
            System.out.println("debug:sum = " + sum);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (SecurityException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (IllegalArgumentException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
    }

参考文章

[1]. ClassLoader的工作原理
[2]. PathClassLoader源码
[3]. MultiDex源码

作者:NUPTboyZHB 发表于2015/12/27 10:41:47 原文链接
阅读:0 评论:0 查看评论

相关 [android 插件 multidex] 推荐:

Android插件化(一):使用改进的MultiDex动态加载assets中的apk

- - CSDN博客推荐文章
Android插件化(一):使用改进的MultiDex动态加载assets中的apk. 为了解决65535方法数超标的问题,Google推荐使用MultiDex来加载classes2.dex,classes3.dex等等,其基本思想就是在运行时动态修改ClassLoader,以达到动态加载类的目的.

DexClassLoader 实现 Android 插件加载

- - RincLiu.com
Java 中的 ClassLoader:. Java 中 ClassLoader 用于动态加载 Class 到 JVM,包含. BootstrapClassLoader(C++ 编写,用于加载系统核心类)、 ExtClassLoader(用于加载 lib/ext/ 目录的扩展 API)、 AppClassLoader(加载 CLASSPATH 目录下的类).

基于PhoneGap2.9框架的android插件的实现

- - CSDN博客移动开发推荐文章
       PhoneGap平台提供了插件功能,开发者可以将重量级的功能封装在原生代码开发的插件中,并将接口暴露给JavaScript,JavaScript调用插件功能即可完成与本地代码的交互. 开发一个简单的android插件主要分以下几步:. (1)编写JAVA类,继承CordovaPlugin类,如下:.

基于插件开发的Android实现流程

- - CSDN博客推荐文章
转载请注明地址:http://blog.csdn.net/droyon/article/details/20951797. 本文记述“柯元旦”Android内核剖析中基于类装载器的“插件”架构. 1、插件不能独立运行,而必须运行于一个宿主程序中,即由宿主程序去调用插件程序. 3、宿主程序中可以管理不同的插件,包括查看插件的数目,禁用或者使用某个插件.

Android插件化方案 RePlugin/README_CN.md at dev · Qihoo360/RePlugin · GitHub

- -
RePlugin —— 历经三年多考验,数亿设备使用的,稳定占坑类插件化方案. RePlugin是一套完整的、稳定的、适合全面使用的,占坑类插件化方案,由360手机卫士的RePlugin Team研发,也是业内首个提出”全面插件化“(全面特性、全面兼容、全面使用)的方案. 极其灵活:主程序无需升级(无需在Manifest中预埋组件),即可支持新增的四大组件,甚至全新的插件.

Android 使用动态加载框架DL进行插件化开发

- - CSDN博客移动开发推荐文章
如有转载,请声明出处: 时之沙:  http://blog.csdn.net/t12x3456    (来自时之沙的csdn博客).         随着应用的不断迭代,应用的体积不断增大,项目越来越臃肿,冗余增加.项目新功能的添加,无法确定与用户匹配性,发生严重异常往往牵一发而动全身,只能紧急发布补丁版本,强制用户进行更新.结果频繁的更新,反而容易降低用户使用黏性.或者是公司业务的不断发展,同系的应用越来越多,传统方式需要通过用户量最大的主项目进行引导下载并安装..

jquery 插件

- - JavaScript - Web前端 - ITeye博客
 jQuery插件的开发包括两种:. 一种是类级别的插件开发,即给jQuery添加新的全局函数,相当于给jQuery类本身添加方法. jQuery的全局函数就是属于jQuery命名空间的函数,另一种是对象级别的插件开发,即给jQuery对象添加方法. 下面就两种函数的开发做详细的说明. 1 、类级别的插件开发.

Android 遥控车

- CasparZ - LinuxTOY
您确定您真的会用 Android 手机玩赛车. 16 岁的法国学生 Jonathan Rico 使用 Android 手机通过蓝牙实现了对改装玩具汽车的遥控. 操控的方式和那些标榜的智能手机游戏一样,使用重力感应,差别是这次控制的是现实世界中的遥控汽车. 收藏到 del.icio.us |.

Android免费?毛

- Ruby - FeedzShare
来自: 36氪 - FeedzShare  . 发布时间:2011年08月17日,  已有 2 人推荐. 微软CEO Steve Ballmer在预测竞争对手产品时通常口无遮拦. 比如他去年抨击Google的Android战略时,很多人都不屑一顾. 接着Android蚕食了微软的地盘,后来又开始侵犯苹果的地盘.