Android 进行单元测试难在哪-part4

发表于 5年以前  | 总阅读数:1880 次

Android 进行单元测试难在哪-part4

上一篇博文中,我给大家介绍了新的应用架构方式 - Square 大法,就像我之前说的,Square 大法是 Square 用于使 Fragment 内的业务逻辑能够进行单元测试的通用方法,我还给大家展示了如何使用 Square 大法重构 Google 的 IOSched 应用的 SessionCalendarService 类,使得对 SessionCalendarService 类内的业务逻辑进行单元测试由几乎不可能变为可行。而在今天的这篇博文中,我会和大家一起继续探索 Square 大法,让我们对应用的 UI 组件进行单元测试成为可能,也让测试变得容易。

这篇博文有“依赖”

将 Sqaure 大法应用到 App 的 UI 组件中(例如 Activity 和 Fragment) 比起将它应用到无 UI 的应用中要复杂一些,造成这种情况的根源正好与我们重构代码的核心方法相关联,解决了这些额外的复杂性问题,我们也就能够改变对 UI 组件进行单元测试时需要使用的预测试状态,以及修改测试后状态。如果你听到“预测试状态”和“测试后状态”后感觉一头雾水,或者觉得有一点点印象却不记得具体是什么意思的话,最好复习复习我之前写的[Android 进行单元测试难在哪-part1](https://github.com/bboyfeiyu/android-tech-frontier/blob/master/issue-9/Android%20%E8%BF%9B%E8%A1%8C%E5%8D%95%E5%85%83%E6%B5%8B%E8%AF%95%E9%9A%BE%E5%9C%A8%E5%93%AA-part1.md)。如果你清楚地理解这两个概念的话,确保你知道 SessionDetailActivity 干了啥。为了学习该如何将 Square 大法应用到应用的 UI 组件类中,我们将重构 SessionDetailActivity 类的代码,使我们能够对类中的 onStop() 方法中的业务逻辑进行单元测试。

开始之前我在唠叨几句吧,如果你有了解过 MVP 模式的话对你理解用 Square 大法重构应用 UI 组件大有裨益,不过呢,由于 Square 已经写了一篇非常精彩的博文介绍 MVP 模式了,我就不再给大家介绍啦,有兴趣的话看 Square 写的博文就好了。如果你在学习 MVP 模式的时候觉得很难理解与 View 相关的操作,不妨看看我之前写的一篇博文,这篇博文能帮你区分进行 Android 开发时我们使用的 View 和 MVP 模式中的“View”。除此以外,我在这篇博文中将 Presenter 用于更新应用界面显示的对象称为 “ViewTranslator” 而不是 “View”。

用 Square 大法重构应用 UI 组件类

虽然用 Square 大法重构应用的 UI 组件可能会更复杂些,但我们的重构策略始终没有发生变化:抽取出应用组件类中的业务逻辑(例如 Activity,Fragment,Service),并将业务逻辑放到我之前说的被进行了依赖注入的“业务对象”中,也就是与 Android 无关接口的 Android 特定实现。

下面是重构后的 onStop() 方法:

@Override
public void onStop() {
    super.onStop();
    if (mInitStarred != mStarred) {
        if (UIUtils.getCurrentTime(this) < mSessionStart) {
            // Update Calendar event through the Calendar API on Android 4.0 or new versions.
            Intent intent = null;
            if (mStarred) {
                // Set up intent to add session to Calendar, if it doesn't exist already.
                intent = new Intent(SessionCalendarService.ACTION_ADD_SESSION_CALENDAR,
                        mSessionUri);
                intent.putExtra(SessionCalendarService.EXTRA_SESSION_START,
                        mSessionStart);
                intent.putExtra(SessionCalendarService.EXTRA_SESSION_END,
                        mSessionEnd);
                intent.putExtra(SessionCalendarService.EXTRA_SESSION_ROOM, mRoomName);
                intent.putExtra(SessionCalendarService.EXTRA_SESSION_TITLE, mTitleString);
            } else {
                // Set up intent to remove session from Calendar, if exists.
                intent = new Intent(SessionCalendarService.ACTION_REMOVE_SESSION_CALENDAR,
                        mSessionUri);
                intent.putExtra(SessionCalendarService.EXTRA_SESSION_START,
                        mSessionStart);
                intent.putExtra(SessionCalendarService.EXTRA_SESSION_END,
                        mSessionEnd);
                intent.putExtra(SessionCalendarService.EXTRA_SESSION_TITLE, mTitleString);
            }
            intent.setClass(this, SessionCalendarService.class);
            startService(intent);

            if (mStarred) {
                setupNotification();
            }
        }
    }
}

正如我之前提到的,这段代码存在一个问题:代码并没有通过被注入到 SessionDetailActivity 的依赖中的方法启动 SessionCalendarService。那我们现在就用 Square 大法来解决这个问题。首先,我们将业务逻辑抽取出来,并放入业务对象中,Square 的工程师们为这个在 Activity(或 Fragment,或其他……)中使用的业务对象起了一个名字 —— “Presenter”。

Presenter 负责用 Model 中的数据更新 View,为了使单元测试在 Presenter 中是可行的,这就意味着 Model 和 View 都必须是注入到 Presenter 中的依赖。也正是这三个对象的组合使用构成了我们所说的 MVP 架构模式。

下面就是 SessionDetailPresenter 中与 onStop() 方法等价的实现啦:

public class SessionDetailViewPresenter implements RepositoryManagerCallbacks {

    public SessionDetailViewPresenter(SessionDetailView sessionDetailView,
                                      RepositoryManager loaderManager,
                                      ServiceStarter serviceStarter,
                                      long calendarId
                                      ) {

        mSessionDetailView = sessionDetailView;
        mLoaderManager = loaderManager;
        mServiceStarter = serviceStarter;
        mCalendarId = calendarId;
    }

    //...

    public void onViewTranslatorStopped() {

        if (mInitStarred != mStarred) {

            if (System.currentTimeMillis() < mSessionStart) {

                CalendarSession calendarSession = new CalendarSession(mSessionUri, mSessionStart, mSessionEnd, mTitleString, mRoomName);

                if (mStarred) {

                    mServiceStarter.startAddCalendarSessionService(mCalendarId, calendarSession);

                } else {

                    mServiceStarter.startRemoveCalendarSessionService(mCalendarId, calendarSession);
                }
            }

            if (mStarred) {

                setupSessionNotification();
            }
        }
    }

    //...
}

完成这个类的关键在于:SessionDetailPresenter 的依赖通过它的构造器传递,因为这些依赖都被注入了,所以我们现在可以修改 SessionDetailPresenter 类中 onViewTranslatorStopped() 方法的测试单元的测试后状态。

package com.google.samples.apps.iosched.test;

import com.google.samples.apps.iosched.service.CalendarSession;
import com.google.samples.apps.iosched.ui.RepositoryManager;
import com.google.samples.apps.iosched.ui.ServiceStarter;
import com.google.samples.apps.iosched.ui.sessiondetail.SessionDetailViewPresenter;
import com.google.samples.apps.iosched.ui.sessiondetail.SessionDetailViewTranslator;

import junit.framework.TestCase;

import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;

/**
 * Created by MattDupree on 5/8/15.
 */
public class SessionDetailPresnterTests extends TestCase {


    public void testShouldLaunchAddSessionService() {

        //Arrange
        SessionDetailViewTranslator sessionDetailViewTranslator = mock(SessionDetailViewTranslator.class);

        RepositoryManager repositoryManager = mock(RepositoryManager.class);

        ServiceStarter serviceStarter = mock(ServiceStarter.class);

        long calendarId = 0;

        SessionDetailViewPresenter sessionDetailViewPresenter = new SessionDetailViewPresenter(sessionDetailViewTranslator,
                                                                                               repositoryManager,
                                                                                               serviceStarter,
                                                                                               calendarId);
        sessionDetailViewPresenter.onViewCreated(null);

        //Act
        sessionDetailViewPresenter.onViewStopped();

        //Assert
        verify(serviceStarter).startAddCalendarSessionService(anyLong(), any(CalendarSession.class));

    }

}

虽然我们现在可以修改测试单元的测试后状态了,但这还不够,因为这个测试单元无法完成测试。为什么呢?我们不妨一起看看 onViewTranslatorStopped() 方法:

public void onViewTranslatorStopped() {

    if (mInitStarred != mStarred) {

        if (System.currentTimeMillis() < mSessionStart) {

            CalendarSession calendarSession = new CalendarSession(mSessionUri, mSessionStart, mSessionEnd, mTitleString, mRoomName);

            if (mStarred) {

                mServiceStarter.startAddCalendarSessionService(mCalendarId, calendarSession);

            } else {

                mServiceStarter.startRemoveCalendarSessionService(mCalendarId, calendarSession);
            }
        }

        if (mStarred) {

            setupSessionNotification();
        }
    }
}

onViewTranslatorStopped() 方法的代码被包裹在一个判断模块中,只有在“starred button”的状态与其初始化状态相异时才会执行判断模块中的代码。mInitStarred 将会在 Loader 回调中被初始化,IOSched 应用检索数据库以判断用户选中的 I/O 大会是否已经被添加到日历中,并在用户返回到 SessionDetailActivity 后通过消息更新 UI。但在上面这段业务逻辑的测试单元中,mInitStarred 和 mStarred 都将有一个初始值 false,使得判断模块内的代码永远不会被执行。

即使我们能执行判断模块内的代码,我们还是不能获得进行单元测试所需要的一切,因为启动 SessionCalendarService 的代码在另一个用于确保在 System.currentTimeMillis() 的返回值小于 mSessionStart 时才执行相关代码的判断模块中。既然我们不能修改 mSessionStart 的值,也就不能保证启动 SessionCalendarService 的代码会被运行。

这些问题都是我思考如何在 Android 中进行单元测试时想到的普遍问题的极端例子:我们常常缺乏对测试单元预测试状态的控制力。然而,由于我们将 SessionRepositoryManager 注入到 SessionDetailPresenter 中,我们现在可以判断 mSessionStart 和 mInitStarred 的值了,因为 SessionRepositoryManager 是一个 Android 无关的接口¹:

package com.google.samples.apps.iosched.ui;

import android.os.Bundle;

import com.google.samples.apps.iosched.io.model.Session;

/**
 * 
 * Created by MattDupree on 5/6/15.
 */
public interface SessionRepositoryManager {

    void initRepository(int id, Bundle bundle, SessionRepositoryManagerCallbacks repositoryManagerCallbacks);


    interface SessionRepositoryManagerCallbacks {

        void onLoadFinished(Session session);
    }
}

然而,当我们创建 SessionDetailPresenter,我们注入了包含 LoaderManager 的 Android 无关接口 SessionRepositoryManager 的实现。

public class SessionDetailActivity extends Activity implements SessionDetailViewTranslator {

    @Override
    public void onCreate(Bundle savedInstanceState)

        //...

        ServiceStarter serviceStarter = new AndroidServiceStarter(this);

        SessionRepositoryManager repositoryManager = new AndroidSessionRepositoryManager(getLoaderManager());

        mSessionDetailViewPresenter = new SessionDetailViewPresenter(this, repositoryManager, 
                                                                     serviceStarter, calendarId);
        mSessionDetailViewPresenter.onViewCreated(savedInstanceState);

        //...
    }

}

因为 SessionRepositoryManager 只是一个接口,所以我们能轻松地定义 MockRepositoryManager 帮助我们完成单元测试:

package com.google.samples.apps.iosched.ui.sessiondetail;

import android.os.Bundle;

import com.google.samples.apps.iosched.io.model.Session;

/**
 * Created by MattDupree on 5/8/15.
 */
public class MockSessionRepositoryManager implements SessionRepositoryManager{


    private Session mSession;

    public MockSessionRepositoryManager(Session session) {

        mSession = session;
    }


    @Override
    public void initRepository(int id, Bundle bundle,
                               SessionRepositoryManagerCallbacks repositoryManagerCallbacks) {

        repositoryManagerCallbacks.onLoadFinished(mSession);
    }
}

不知道大家有没有注意到:当有操作通过向构造器传递一个 Session 对象调用 initRepository() 方法时,我们能够指定 MockSessionRepositoryManager 的返回值。SessionDetailPresenter 中类似 mSessionStart 这样的值能在 Session 的模板对象中通过 startTimeStamp 实例初始化。这样一来,我们就能掌控这些值了,也就是说,我们现在几乎拥有了在对 onViewTranslatorStopped() 方法进行单元测试时,准备阶段所需要的一切。

public void testShouldLaunchAddSessionService() {

    //Arrange
    SessionDetailViewTranslator sessionDetailViewTranslator = mock(SessionDetailViewTranslator.class);

    Session session = new Session();
    session.startTimestamp = "1431081943";

    SessionRepositoryManager repositoryManager = new MockSessionRepositoryManager(session);

    ServiceStarter serviceStarter = mock(ServiceStarter.class);

    long calendarId = 0;

    SessionDetailViewPresenter sessionDetailViewPresenter = new SessionDetailViewPresenter(sessionDetailViewTranslator,
                                                                                           repositoryManager,
                                                                                           serviceStarter,
                                                                                           calendarId);
    sessionDetailViewPresenter.onViewCreated(null);

    //Act
    sessionDetailViewPresenter.onViewStopped();

    //Assert
    verify(serviceStarter).startAddCalendarSessionService(anyLong(), any(CalendarSession.class));

}

我之所以说“几乎”,是因为 onViewTranslatorStopped() 方法中还有一个地方不能通过上面的代码处理。在 onViewTranslatorStopped() 方法的最下面有一个只有在 mStarred 的值为 true 时才会运行的代码块。这段代码会启动一个 Service 提醒用户参与并且/或者排列他们添加到日历中的 IO 大会:

public class SessionDetailViewPresenter implements RepositoryManagerCallbacks {

    public SessionDetailViewPresenter(SessionDetailView sessionDetailView,
                                      RepositoryManager loaderManager,
                                      ServiceStarter serviceStarter,
                                      long calendarId
                                      ) {

        mSessionDetailView = sessionDetailView;
        mLoaderManager = loaderManager;
        mServiceStarter = serviceStarter;
        mCalendarId = calendarId;
    }

    //...

    public void onViewTranslatorStopped() {

        if (mInitStarred != mStarred) {

            if (System.currentTimeMillis() < mSessionStart) {

                CalendarSession calendarSession = new CalendarSession(mSessionUri, mSessionStart, mSessionEnd, mTitleString, mRoomName);

                if (mStarred) {

                    mServiceStarter.startAddCalendarSessionService(mCalendarId, calendarSession);

                } else {

                    mServiceStarter.startRemoveCalendarSessionService(mCalendarId, calendarSession);
                }
            }

            if (mStarred) {

                setupSessionNotification();
            }
        }
    }

    //...
}

为了让这段代码运行,我们需要确保 mStarred 的值为 true。我们能通过调用 SessionDetailPresenter 的 onSessionStarred() 方法完成这项工作,因为 onSessionStarred() 方法就是在用户点击 star button 时 SessionDetailViewTranslator(如果你觉得这些命名让你觉得晕乎乎的,你可以把它当作 SessionDetailView)调用的方法。

public void onToggleSessionStarred() {

    mStarred = !mStarred;
}
public void testShouldLaunchAddSessionService() {

    //Arrange
    SessionDetailViewTranslator sessionDetailViewTranslator = mock(SessionDetailViewTranslator.class);

    Session session = new Session();
    session.startTimestamp = "1431081943";

    SessionRepositoryManager repositoryManager = new MockSessionRepositoryManager(session);

    ServiceStarter serviceStarter = mock(ServiceStarter.class);

    long calendarId = 0;

    SessionDetailViewPresenter sessionDetailViewPresenter = new SessionDetailViewPresenter(sessionDetailViewTranslator,
                                                                                           repositoryManager,
                                                                                           serviceStarter,
                                                                                           calendarId);
    sessionDetailViewPresenter.onViewCreated(null);
    //****** We call onToggleSessionStarred() to make sure that mStarrred is true
    sessionDetailViewPresenter.onToggleSessionStarred();
    //******

    //Act
    sessionDetailViewPresenter.onViewStopped();

    //Assert
    verify(serviceStarter).startAddCalendarSessionService(anyLong(), any(CalendarSession.class));

}

把上面提到的所有工作万仇后,我们终于能够在 onViewTranslatorStopped() 方法中进行单元测试了。

结论

你可能觉得我们为单元测试的准备阶段进行了大量的工作,我不得不承认你的感觉是对的。最后,我提一点个人想法:我认为 SessionDetailActivity 类中的代码实在是太多了,都有1000多行……正是这个原因,使得为其实现测试单元变得如此艰难。此外,因为这篇博文的目的只是展示 Square 大法的核心用法,所以我并不打算讨论优化单元测试的方案。²

Square 大法是告别传统 Android 开发架构的里程碑,因为我们确实发现了遵循传统架构进行开发的种种缺陷。为此,我将在下一篇博文中指出 Square 大法潜在的问题,并提出可能的解决办法。此外,本系列的最后一篇博文也会告诉大家 Square 大法的种种优点,而且我所说的优点可不是只有增强应用的可测试性哦。

注:

  1. 严格来说这个接口并不是 Android 无关的,因为它的核心方法使用 Bundle 作为参数,但我不确定这会不会带来什么问题,毕竟 Bundle 非常普通,并不会是什么我们想要测试的东西。退一万步说,即使要对它进行测试也没啥麻烦。

  2. 在 Droidcon Montreal 中,Richa Khandelwal 在 Coursa 上开了一节课建议我们使用更简洁、更易于测试的架构进行开发,这或许也能让实现单元测试变得简单些。

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
简化Android的UI开发 5年以前  |  521177次阅读
Android 深色模式适配原理分析 4年以前  |  29536次阅读
Android阴影实现的几种方案 2年以前  |  12057次阅读
Android 样式系统 | 主题背景覆盖 4年以前  |  10211次阅读
 目录