Virtual APK是滴滴出行自研的一款优秀的插件化框架,其主要开发人员有任玉刚老师
说到任玉刚老师,他可以说是我Android FrameWork层的启蒙老师。刚接触Android的时候,在拖了几年控件、写了一些CURD操作后,就得出了这样的结论:客户端太无聊了,现在已经完全精通安卓开发了。直到有一天看了一本叫做《Android开发艺术探索》的书,不禁感慨:原来Android开发竟然还能这么玩,之前的认知实在是浅薄
言归正传,Virtual APK的特性和使用方法不是本文重点,如有需要了解更多请移步VirtualAPK的特性和使用方法。本文主要针对Virtual APK的实现做讲解。
中心思想:
插件Apk的加载在PluginManager#loadPlugin
方法,在加载完成后,会生成一个LoadedPlugin
对象并保存在Map中。LoadedPlugin里保存里插件Apk里绝大多数的重要信息和一个DexClassLoader,这个DexClassLoader是作为插件Apk的类加载器使用。
看下LoadedPlugin的具体实现,注释标明了各个属性的含义:
public LoadedPlugin(PluginManager pluginManager, Context context, File apk) throws Exception {
// PluginManager
this.mPluginManager = pluginManager;
// 宿主Context
this.mHostContext = context;
// 插件apk路径
this.mLocation = apk.getAbsolutePath();
this.mPackage = PackageParserCompat.parsePackage(context, apk, PackageParser.PARSE_MUST_BE_APK);
// 插件apk metadata
this.mPackage.applicationInfo.metaData = this.mPackage.mAppMetaData;
// 插件apk package信息
this.mPackageInfo = new PackageInfo();
this.mPackageInfo.applicationInfo = this.mPackage.applicationInfo;
this.mPackageInfo.applicationInfo.sourceDir = apk.getAbsolutePath();
// 插件apk 签名信息
if (Build.VERSION.SDK_INT >= 28
|| (Build.VERSION.SDK_INT == 27 && Build.VERSION.PREVIEW_SDK_INT != 0)) { // Android P Preview
try {
this.mPackageInfo.signatures = this.mPackage.mSigningDetails.signatures;
} catch (Throwable e) {
PackageInfo info = context.getPackageManager().getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
this.mPackageInfo.signatures = info.signatures;
}
} else {
this.mPackageInfo.signatures = this.mPackage.mSignatures;
}
// 插件apk 包名
this.mPackageInfo.packageName = this.mPackage.packageName;
// 如果已经加载过相同的apk, 抛出异常
if (pluginManager.getLoadedPlugin(mPackageInfo.packageName) != null) {
throw new RuntimeException("plugin has already been loaded : " + mPackageInfo.packageName);
}
this.mPackageInfo.versionCode = this.mPackage.mVersionCode;
this.mPackageInfo.versionName = this.mPackage.mVersionName;
this.mPackageInfo.permissions = new PermissionInfo[0];
this.mPackageManager = createPluginPackageManager();
this.mPluginContext = createPluginContext(null);
this.mNativeLibDir = getDir(context, Constants.NATIVE_DIR);
this.mPackage.applicationInfo.nativeLibraryDir = this.mNativeLibDir.getAbsolutePath();
// 创建插件的资源管理器
this.mResources = createResources(context, getPackageName(), apk);
// 创建 一个dexClassLoader
this.mClassLoader = createClassLoader(context, apk, this.mNativeLibDir, context.getClassLoader());
tryToCopyNativeLib(apk);
// Cache instrumentations
Map<ComponentName, InstrumentationInfo> instrumentations = new HashMap<ComponentName, InstrumentationInfo>();
for (PackageParser.Instrumentation instrumentation : this.mPackage.instrumentation) {
instrumentations.put(instrumentation.getComponentName(), instrumentation.info);
}
this.mInstrumentationInfos = Collections.unmodifiableMap(instrumentations);
this.mPackageInfo.instrumentation = instrumentations.values().toArray(new InstrumentationInfo[instrumentations.size()]);
// Cache activities
// 保存插件apk的Activity信息
Map<ComponentName, ActivityInfo> activityInfos = new HashMap<ComponentName, ActivityInfo>();
for (PackageParser.Activity activity : this.mPackage.activities) {
activity.info.metaData = activity.metaData;
activityInfos.put(activity.getComponentName(), activity.info);
}
this.mActivityInfos = Collections.unmodifiableMap(activityInfos);
this.mPackageInfo.activities = activityInfos.values().toArray(new ActivityInfo[activityInfos.size()]);
// Cache services
// 保存插件apk的Service信息
Map<ComponentName, ServiceInfo> serviceInfos = new HashMap<ComponentName, ServiceInfo>();
for (PackageParser.Service service : this.mPackage.services) {
serviceInfos.put(service.getComponentName(), service.info);
}
this.mServiceInfos = Collections.unmodifiableMap(serviceInfos);
this.mPackageInfo.services = serviceInfos.values().toArray(new ServiceInfo[serviceInfos.size()]);
// Cache providers
// 保存插件apk的ContentProvider信息
Map<String, ProviderInfo> providers = new HashMap<String, ProviderInfo>();
Map<ComponentName, ProviderInfo> providerInfos = new HashMap<ComponentName, ProviderInfo>();
for (PackageParser.Provider provider : this.mPackage.providers) {
providers.put(provider.info.authority, provider.info);
providerInfos.put(provider.getComponentName(), provider.info);
}
this.mProviders = Collections.unmodifiableMap(providers);
this.mProviderInfos = Collections.unmodifiableMap(providerInfos);
this.mPackageInfo.providers = providerInfos.values().toArray(new ProviderInfo[providerInfos.size()]);
// 将所有静态注册的广播全部改为动态注册
Map<ComponentName, ActivityInfo> receivers = new HashMap<ComponentName, ActivityInfo>();
for (PackageParser.Activity receiver : this.mPackage.receivers) {
receivers.put(receiver.getComponentName(), receiver.info);
BroadcastReceiver br = BroadcastReceiver.class.cast(getClassLoader().loadClass(receiver.getComponentName().getClassName()).newInstance());
for (PackageParser.ActivityIntentInfo aii : receiver.intents) {
this.mHostContext.registerReceiver(br, aii);
}
}
this.mReceiverInfos = Collections.unmodifiableMap(receivers);
this.mPackageInfo.receivers = receivers.values().toArray(new ActivityInfo[receivers.size()]);
// try to invoke plugin's application
// 创建插件apk的Application对象
invokeApplication();
}
Virtual APK启动插件APK中Activity的整体方案:
插桩Activity
,这些插桩Activity并不会真正的启动,而是对AMS进行欺骗。如果启动的Activity是插件APK中的,则根据该Actiivty的启动模式选择合适的插桩Activity, AMS在启动阶段对插桩Activity处理后,在创建Activity实例阶段,实际创建插件APK中要启动的Activity。插桩Activity有很多个,挑一些看一下:
<!-- Stub Activities -->
<activity android:exported="false" android:name=".A$1" android:launchMode="standard"/>
<activity android:exported="false" android:name=".A$2" android:launchMode="standard"
android:theme="@android:style/Theme.Translucent" />
<!-- Stub Activities -->
<activity android:exported="false" android:name=".B$1" android:launchMode="singleTop"/>
<activity android:exported="false" android:name=".B$2" android:launchMode="singleTop"/>
<activity android:exported="false" android:name=".B$3"
protected void hookInstrumentationAndHandler() {
try {
// 获取当前进程的activityThread
ActivityThread activityThread = ActivityThread.currentActivityThread();
// 获取当前进程的Instrumentation
Instrumentation baseInstrumentation = activityThread.getInstrumentation();
// 创建自定义Instrumentation
final VAInstrumentation instrumentation = createInstrumentation(baseInstrumentation);
// 将当前进程原有的Instrumentation对象替换为自定义的
Reflector.with(activityThread).field("mInstrumentation").set(instrumentation);
// 将当前进程原有的主线程Hander的callback替换为自定义的
Handler mainHandler = Reflector.with(activityThread).method("getHandler").call();
Reflector.with(mainHandler).field("mCallback").set(instrumentation);
this.mInstrumentation = instrumentation;
Log.d(TAG, "hookInstrumentationAndHandler succeed : " + mInstrumentation);
} catch (Exception e) {
Log.w(TAG, e);
}
}
如果我们熟悉Activity启动流程的话,我们一定知道Activity的启动和生命周期管理,都间接通过Instrumentation进行管理的。--如果不熟悉也没关系,可以看我之前写的AMS系列文章,看完保证秒懂(雾)。VAInstrumentation重写了这个类的一些重要方法,我们根据Activity启动流程一个一个说
这个方法有很多个重载,挑其中一个:
public ActivityResult execStartActivity(Context who, IBinder contextThread, IBinder token, Activity target, Intent intent, int requestCode) {
// 对原始Intent进行处理
injectIntent(intent);
return mBase.execStartActivity(who, contextThread, token, target, intent, requestCode);
}
injectIntent
方法对Intent的处理在ComponentsHandler#markIntentIfNeeded
方法,对原始Intent进行解析,获取目标Actiivty的包名和类名,如果目标Activity的包名和当前进程不同且该包名对应的LoadedPlugin对象存在,则说明它是我们加载过的插件APK中的Activity,则对该Intent的目标进行替换:
public void markIntentIfNeeded(Intent intent) {
...
String targetPackageName = intent.getComponent().getPackageName();
String targetClassName = intent.getComponent().getClassName();
// 判断是否需要启动的是插件Apk的Activity
if (!targetPackageName.equals(mContext.getPackageName()) && mPluginManager.getLoadedPlugin(targetPackageName) != null) {
...
// 将原始Intent的目标Acitivy替换为预设的插桩Activity中的一个
dispatchStubActivity(intent);
}
}
dispatchStubActivity方法根据原始Intent的启动模式选择合适的插桩Activity,将原始Intent中的类名修改为插桩Activity的类名,示例代码:
case ActivityInfo.LAUNCH_SINGLE_TOP: {
usedSingleTopStubActivity = usedSingleTopStubActivity % MAX_COUNT_SINGLETOP + 1;
stubActivity = String.format(STUB_ACTIVITY_SINGLETOP, corePackage, usedSingleTopStubActivity);
break;
}
case ActivityInfo.LAUNCH_SINGLE_TASK: {
usedSingleTaskStubActivity = usedSingleTaskStubActivity % MAX_COUNT_SINGLETASK + 1;
stubActivity = String.format(STUB_ACTIVITY_SINGLETASK, corePackage, usedSingleTaskStubActivity);
break;
}
case ActivityInfo.LAUNCH_SINGLE_INSTANCE: {
usedSingleInstanceStubActivity = usedSingleInstanceStubActivity % MAX_COUNT_SINGLEINSTANCE + 1;
stubActivity = String.format(STUB_ACTIVITY_SINGLEINSTANCE, corePackage, usedSingleInstanceStubActivity);
break;
}
如果只是对原始Intent进行替换,那么最终启动的会是插桩Activity,这显然达不到启动插件Apk中Acitivty的目的,在Activity实例创建阶段,还需要对实际创建的Actiivty进行替换,方法在VAInstrumentation#newActivity
:
@Override
public Activity newActivity(ClassLoader cl, String className, Intent intent) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
try {
cl.loadClass(className);
Log.i(TAG, String.format("newActivity[%s]", className));
} catch (ClassNotFoundException e) {
ComponentName component = PluginUtil.getComponent(intent);
String targetClassName = component.getClassName();
Log.i(TAG, String.format("newActivity[%s : %s/%s]", className, component.getPackageName(), targetClassName));
LoadedPlugin plugin = this.mPluginManager.getLoadedPlugin(component);
// 使用在LoadedPlugin对象中创建的DexClassLoader进行类加载,该ClassLoader指向插件APK所在路径
Activity activity = mBase.newActivity(plugin.getClassLoader(), targetClassName, intent);
activity.setIntent(intent);
// 插件Activity实例创建后,将Resource替换为插件APK的资源
Reflector.QuietReflector.with(activity).field("mResources").set(plugin.getResources());
return newActivity(activity);
}
return newActivity(mBase.newActivity(cl, className, intent));
}
如果我们启动的是插件APK里的Activity,这个方法的Catch语句块是一定会被执行的,因为入参className已经被替换为插桩Activity的,但是我们只是在宿主App的AndroidManifest.xml中定义了这些Actiivty,并没有真正的实现。在进入Catch语句块后,使用LoadedPlugin中保存的DexClassloader进行Activity的创建。
看到这里,可能就会有同学有问题了,你把要启动的Activity给替换了,但是AMS中不是还记录的是插桩Actiivty么,那么这个Activity实例后续跟AMS的交互怎么办?那岂不是在AMS中的记录找不到了?放心,不会出现这个问题的。复习之前AMS系列文章我们就会知道,AMS中对Activity管理的依据是一个叫appToken的Binder实例,在客户端对应的token会在Instrumentation#newActivity执行完成后调用Activity#attach方法传递给Actiivty。
这也是为什么对AMS进行欺骗这种插件化方案可行的原因,因为后续管理是使用的token,如果Android使用className之类的来管理的话,恐怕这种方案就不太好实现了。
在系统创建插件Activity的Context创建完成之后,需要将其替换为PluginContext,PluginContext和Context的区别是其内部保存有一个LoadedPlugin对象,方便对Context中的资源进行替换。代码在VAInstrumentaiton#injectActivity
,调用处在VAInstrumentaiton#callActivityOnCreate
protected void injectActivity(Activity activity) {
final Intent intent = activity.getIntent();
if (PluginUtil.isIntentFromPlugin(intent)) {
Context base = activity.getBaseContext();
try {
LoadedPlugin plugin = this.mPluginManager.getLoadedPlugin(intent);
Reflector.with(base).field("mResources").set(plugin.getResources());
Reflector reflector = Reflector.with(activity);
reflector.field("mBase").set(plugin.createPluginContext(activity.getBaseContext()));
reflector.field("mApplication").set(plugin.getApplication());
// set screenOrientation
ActivityInfo activityInfo = plugin.getActivityInfo(PluginUtil.getComponent(intent));
if (activityInfo.screenOrientation != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) {
activity.setRequestedOrientation(activityInfo.screenOrientation);
}
// for native activity
ComponentName component = PluginUtil.getComponent(intent);
Intent wrapperIntent = new Intent(intent);
wrapperIntent.setClassName(component.getPackageName(), component.getClassName());
wrapperIntent.setExtrasClassLoader(activity.getClassLoader());
activity.setIntent(wrapperIntent);
} catch (Exception e) {
Log.w(TAG, e);
}
}
}
Virtual APK启动插件APK中Activity的整体方案:
IActivityManager是AMS的实现接口,它的实现类分别是ActivityManagerService和其proxy
这里我们需要代理的是Proxy,实现方法在PluginManager#hookSystemServices
protected void hookSystemServices() {
try {
Singleton<IActivityManager对象> defaultSingleton;
// 获取IActivityManager对象
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
defaultSingleton = Reflector.on(ActivityManager.class).field("IActivityManagerSingleton").get();
} else {
defaultSingleton = Reflector.on(ActivityManagerNative.class).field("gDefault").get();
}
IActivityManager origin = defaultSingleton.get();
// 创建activityManager对象的动态代理
IActivityManager activityManager对象的动态代理 = (IActivityManager) Proxy.newProxyInstance(mContext.getClassLoader(), new Class[] { IActivityManager.class },
createActivityManagerProxy(origin));
// 使用动态代理替换之前的IActivityManager对象实例
Reflector.with(defaultSingleton).field("mInstance").set(activityManagerProxy);
if (defaultSingleton.get() == activityManagerProxy) {
this.mActivityManager = activityManagerProxy;
Log.d(TAG, "hookSystemServices succeed : " + mActivityManager);
}
} catch (Exception e) {
Log.w(TAG, e);
}
}
通过将动态代理对系统创建的ActivityManager的proxy进行替换,这样,调用AMS方法时,会转到ActivityManagerProxy的invoke方法,并根据方法名对Service的生命周期进行管理,生命周期方法较多,挑选其中一个:
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("startService".equals(method.getName())) {
try {
return startService(proxy, method, args);
} catch (Throwable e) {
Log.e(TAG, "Start service error", e);
}
}
startService:
protected Object startService(Object proxy, Method method, Object[] args) throws Throwable {
IApplicationThread appThread = (IApplicationThread) args[0];
Intent target = (Intent) args[1];
ResolveInfo resolveInfo = this.mPluginManager.resolveService(target, 0);
if (null == resolveInfo || null == resolveInfo.serviceInfo) {
// 插件中没找到,说明是宿主APP自己的Service
return method.invoke(this.mActivityManager, args);
}
// 启动插件APK中的Service
return startDelegateServiceForTarget(target, resolveInfo.serviceInfo, null, RemoteService.EXTRA_COMMAND_START_SERVICE);
}
startDelegateServiceForTarget
中会调用wrapperTargetIntent
处理,最终在RemoteService或者LocalService的onStartCommand中对Service的各生命周期处理。
需要注意的是,在RemoteService中需要重新对APK进行解析和装载,生成LoadedPlugin,因为它运行在另一个进程中。
这也说明插件APK的Service进程如果声明了多个是无效的,因为他们最终都会运行在宿主RemoteService所在进程。
ContentProvicer的处理和Service是类似的,不多说了。
插件APP理论上并不需要做什么特殊处理,唯一需要注意的是资源文件的冲突问题,因此,需要在插件工程app目录下的build.gradle中添加如下代码:
virtualApk {
packageId = 0x6f // the package id of Resources.
targetHost = '../../VirtualAPK/app' // the path of application module in host project.
applyHostMapping = true //optional, default value: true.
}
它的作用是在插件APK编译时对资源ID进行重写,处理方法在ResourceCollector.groovy文件的collect
方法:
def collect() {
//1、First, collect all resources by parsing the R symbol file.
parseResEntries(allRSymbolFile, allResources, allStyleables)
//2、Then, collect host resources by parsing the host apk R symbol file, should be stripped.
parseResEntries(hostRSymbolFile, hostResources, hostStyleables)
//3、Compute the resources that should be retained in the plugin apk.
filterPluginResources()
//4、Reassign the resource ID. If the resource entry exists in host apk, the reassign ID
// should be same with value in host apk; If the resource entry is owned by plugin project,
// then we should recalculate the ID value.
reassignPluginResourceId()
//5、Collect all the resources in the retained AARs, to regenerate the R java file that uses the new resource ID
vaContext.retainedAarLibs.each {
gatherReservedAarResources(it)
}
}
首先获取插件app和宿主app的资源集合,然后寻找其中冲突的资源id进行修改,修改id是 reassignPluginResourceId方法:
private void reassignPluginResourceId() {
// 对资源ID根据typeId进行排序
resourceIdList.sort { t1, t2 ->
t1.typeId - t2.typeId
}
int lastType = 1
// 重写资源ID
resourceIdList.each {
if (it.typeId < 0) {
return
}
def typeId = 0
def entryId = 0
typeId = lastType++
pluginResources.get(it.resType).each {
it.setNewResourceId(virtualApk.packageId, typeId, entryId++)
}
}
}
这里要说一下资源ID的组成:
image.png
资源ID是一个32位的16进制整数,前8位代表app, 接下来8位代表typeId(string、layout、id等),从01开始累加,后面四位为资源id,从0000开始累加。随便反编译了一个apk,看一下其中一部分的结构:
image.png
对资源ID的遍历使用了双重循环,外层循环从01开始对typeId进行遍历,内层循环从0000开始对typeId对应的资源ID进行遍历,并且在内层循环调用setNewResourceId
进行重写:
public void setNewResourceId(packageId, typeId, entryId) {
newResourceId = packageId << 24 | typeId << 16 | entryId
}
packageId是我们在build.gradle中定义的virtualApk.packageId,将其左移24位,与资源id的前8位对应,typeId与第9-16位对应,后面是资源id
这样,在插件app编译过程中就完成了冲突资源id的替换,后面也不会有冲突的问题了
回顾整个Virtual APK的实现,其实逻辑并不是特别复杂,但是可以看到作者们对AMS以及资源加载、类加载器等API的熟悉程度,如果不是对这些知识体系特别精通的话,是很难实现的,甚至连思路都不可能有,这也是我们学习源码的意义所在。
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/VhCariJMWOTS8IMYw5jaCw
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。