理解面对对象的六大原则

241 阅读16分钟

以下内容来自:《Android源码设计模式解析与实战》

说起面对对象的六大原则,可能大部分人都能说出一二来。但是如何应用到自己的代码中却是一个不小的难题。这篇文章会用一个实际的例子,并用六大原则改造,在改造的过程中体会。

我们来看一个Android中常见的功能模块——图片加载。在不使用任何现有框架的前提下,可能会这样写:

public class ImageLoader {
    //图片缓存
    LruCache<String, Bitmap> mImageCache;
    //固定线程数的线程池,线程数为CPU的数量
    ExecutorService mExecutorService =
            Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    public ImageLoader() {
        initImageCache();
    }

    private void initImageCache() {
        //计算可使用的最大内存
        int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
        //使用1/4作为图片缓存
        int cacheSize = maxMemory / 4;
        mImageCache = new LruCache<String, Bitmap>(cacheSize) {
            @Override
            protected int sizeOf(String key, Bitmap value) {
                return value.getRowBytes() * value.getHeight() * 1024;
            }
        };
    }

    public void displayImage(final String url, final ImageView imageView) {
        imageView.setTag(url);
        mExecutorService.submit(new Runnable() {
            @Override
            public void run() {
                Bitmap bitmap = downloadImage(url);
                if (bitmap == null) {
                    return;
                }
                if (imageView.getTag().equals(url)) {
                    imageView.setImageBitmap(bitmap);
                }
                mImageCache.put(url, bitmap);
            }
        });
    }

    private Bitmap downloadImage(String imageUrl) {
        Bitmap bitmap = null;
        try {
            URL url = new URL(imageUrl);
            HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
            bitmap = BitmapFactory.decodeStream(connection.getInputStream());
            connection.disconnect();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return bitmap;
    }
}

这样可以完成图片加载的功能,但是这个ImageLoader严重耦合,毫无设计可言,更不用说扩展性、灵活性了。所有的功能都写在一个类里,随着功能的增加,ImageLoader类会越来越大,代码也越来越复杂,图片加载系统就越来越脆弱。接下来我们尝试用单一职责原则改造以下这个ImageLoader。

单一职责原则

**Single Responsibility Principle **

定义:就一个类而言,应该仅有一个引起它变化的原因。简单来说,一个类中应该是一组相关性很高的函数、数据的封装。

虽然如何划分一个类,一个函数的职责,每个人都有自己的看法,这需要根据个人经验、具体的业务逻辑而定。但完全两个不一样的功能就不应该放在一个类中。因此从单一职责来看ImageLoader,明显它可以分为两个,一个是图片加载;另一个是图片缓存。因此我们这样改造:

public class ImageLoader {
    //图片缓存
    ImageCache mImageCache = new ImageCache();
    //固定线程数的线程池,线程数为CPU的数量
    ExecutorService mExecutorService =
            Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    public void displayImage(final String url, final ImageView imageView) {
        Bitmap bitmap = mImageCache.get(url);
        if (bitmap != null) {
            imageView.setImageBitmap(bitmap);
            return;
        }
        imageView.setTag(url);
        mExecutorService.submit(new Runnable() {
            @Override
            public void run() {
                Bitmap bitmap = downloadImage(url);
                if (bitmap == null) {
                    return;
                }
                if (imageView.getTag().equals(url)) {
                    imageView.setImageBitmap(bitmap);
                }
                mImageCache.put(url, bitmap);
            }
        });
    }

    private Bitmap downloadImage(String imageUrl) {
        Bitmap bitmap = null;
        try {
            URL url = new URL(imageUrl);
            HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
            bitmap = BitmapFactory.decodeStream(connection.getInputStream());
            connection.disconnect();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return bitmap;
    }
}

抽出ImageCache用于处理图片缓存:

public class ImageCache {
    LruCache<String, Bitmap> mImageCache;

    public ImageCache() {
        initImageCache();
    }

    private void initImageCache() {
        //计算可使用的最大内存
        int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
        //使用1/4作为图片缓存
        int cacheSize = maxMemory / 4;
        mImageCache = new LruCache<String, Bitmap>(cacheSize) {
            @Override
            protected int sizeOf(String key, Bitmap value) {
                return value.getRowBytes() * value.getHeight() * 1024;
            }
        };
    }

    public void put(String url, Bitmap bitmap) {
        mImageCache.put(url, bitmap);
    }

    public Bitmap get(String url) {
        return mImageCache.get(url);
    }
}

改造后的ImageLoader只负责图片加载的逻辑,而ImageCache只负责处理图片缓存的逻辑,这样ImageLoader的代码量变少了,职责也清晰了;当缓存相关的逻辑需要改变时,不需要修改ImageLoader类,而图片的加载逻辑需要修改时也不会影响到缓存处理逻辑。

开闭原则

Open Close Principe

定义:软件中的对象(类、模块、函数等)应该对于扩展是开放的,但是对于修改是封闭的。在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会将错误引入原本已经经过测试的旧代码中,破坏原有系统。因此,当软件需要变化时,我们应该尽量通过扩展的方式来实现变化么人不是通过修改已有的代码来实现。当然,在现实开发中,只通过继承的方式来升级、维护原有系统只是一个理想化的愿景,因此,在实际的开发过程中,修改原有代码、扩展代码往往是同时存在的。

我们再来看看ImageLoader,虽然通过内存缓存解决了每次从网络加载图片的问题,但是,Android应用的内存很有限,且具有易失性,即当应用重新启动之后,原来已经加载过的图片将会失去,这样重启之后就需要重新下载!这又会导致加载缓慢、耗费用户流量的问题。引入SD卡缓存可以解决这一个问题,我们在来添加一个SD卡缓存类:

public class DiskCache {
    static String cacheDir = Environment.getExternalStorageDirectory().getAbsolutePath() + "/";

    public Bitmap get(String url) {
        return BitmapFactory.decodeFile(cacheDir + url);
    }

    public void put(String url, Bitmap bitmap) {
        FileOutputStream fileOutputStream = null;
        try {
            fileOutputStream = new FileOutputStream(cacheDir + url);
            bitmap.compress(Bitmap.CompressFormat.PNG, 100, fileOutputStream);
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } finally {
            if (fileOutputStream != null) {
                try {
                    fileOutputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

然后相应的,改动ImageLoader的代码,添加SD卡缓存:

public class ImageLoader {
    //内存缓存
    ImageCache mImageCache = new ImageCache();
    //SD卡缓存
    DiskCache mDiskCache = new DiskCache();
    //是否使用SD卡缓存
    boolean isUseDiskCache = false;
    //固定线程数的线程池,线程数为CPU的数量
    ExecutorService mExecutorService =
            Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    public void displayImage(final String url, final ImageView imageView) {
        //判断使用哪种缓存
        Bitmap bitmap = isUseDiskCache ? mDiskCache.get(url) : mImageCache.get(url);
        if (bitmap != null) {
            imageView.setImageBitmap(bitmap);
            return;
        }
        imageView.setTag(url);
        mExecutorService.submit(new Runnable() {
            @Override
            public void run() {
                Bitmap bitmap = downloadImage(url);
                if (bitmap == null) {
                    return;
                }
                if (imageView.getTag().equals(url)) {
                    imageView.setImageBitmap(bitmap);
                }
                if (isUseDiskCache) {
                    mDiskCache.put(url, bitmap);
                } else {
                    mImageCache.put(url, bitmap);
                }
            }
        });
    }

    public void useDiskCache(boolean useDiskCache) {
        isUseDiskCache = useDiskCache;
    }

    private Bitmap downloadImage(String imageUrl) {
        Bitmap bitmap = null;
        try {
            URL url = new URL(imageUrl);
            HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
            bitmap = BitmapFactory.decodeStream(connection.getInputStream());
            connection.disconnect();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return bitmap;
    }
}

这里增加了一个DiskCache类,用户可以通过useDiskCache方法来使用哪种缓存进行设置,例如:

ImageLoader imageLoader = new ImageLoader();
//使用SD卡缓存
imageLoader.useDiskCache(true);
//使用内存缓存
imageLoader.useDiskCache(false);

但是这样又存在不能同时使用内存缓存和SD卡缓存。应该优先使用内存缓存,如果内存缓存中没有图片再使用SD卡缓存,如果SD卡中也没有图片最后才去网络上获取,这才是最好的缓存策略。我们在添加一种双缓存的类DoubleCache:

public class DoubleCache {
    ImageCache mMemoryCache = new ImageCache();
    DiskCache mDiskCache = new DiskCache();

    public Bitmap get(String url) {
        Bitmap bitmap = mMemoryCache.get(url);
        if (bitmap == null) {
            bitmap = mDiskCache.get(url);
        }
        return bitmap;
    }

    public void put(String url, Bitmap bitmap) {
        mMemoryCache.put(url, bitmap);
        mDiskCache.put(url, bitmap);
    }
}

并且同样应用到ImageLoader中:

public class ImageLoader {
    //内存缓存
    ImageCache mImageCache = new ImageCache();
    //SD卡缓存
    DiskCache mDiskCache = new DiskCache();
    //双缓存
    DoubleCache mDoubleCache = new DoubleCache();
    //使用SD卡缓存
    boolean isUseDiskCache = false;
    //使用双缓存
    boolean isUseDoubleCache = false;
    //固定线程数的线程池,线程数为CPU的数量
    ExecutorService mExecutorService =
            Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    public void displayImage(final String url, final ImageView imageView) {
        //判断使用哪种缓存
        Bitmap bitmap = null;
        if (isUseDoubleCache) {
            bitmap = mDoubleCache.get(url);
        } else if (isUseDiskCache) {
            bitmap = mDiskCache.get(url);
        } else {
            bitmap = mImageCache.get(url);
        }
        if (bitmap != null) {
            imageView.setImageBitmap(bitmap);
            return;
        }
        imageView.setTag(url);
        //没有缓存,则提交给线程池进行异步下载
    }

    public void useDiskCache(boolean useDiskCache) {
        isUseDiskCache = useDiskCache;
    }

    public void useDoubleCache(boolean useDoubleCache) {
        isUseDoubleCache = useDoubleCache;
    }
}

现在即可以使用内存缓存,也可以使用SD卡缓存,或者两者同时使用,但还是存在一些问题。我们来分析一下现在的代码,ImageLoader通过boolean变量来让用户选择使用哪种缓存,因此存在各种if-else判断语句,通过这些判断来确定使用哪种缓存。随着这些逻辑的引入,代码变得越来越复杂、脆弱,如果一不小心写错了某个if条件,那就需要更多的时间来排除,整个ImageLoader类也会变得越来越臃肿。还有一点,用户不能自己实现缓存注入到ImageLoader中,可扩展性差。

这样的代码明显不满足开闭原则,为了满足上面说到的需求,我们可以采用策略模式继续改造,先来看下UML图:

UML图

接着,我们把代码按照UML图改造一下,ImageCache变成了接口,并且有3个实现类:MemoryCache、DiskCache、DoubleCache。

public interface ImageCache {
    void put(String url, Bitmap bitmap);

    Bitmap get(String url);
}

public class MemoryCache implements ImageCache {
    ...
}

public class DiskCache implements ImageCache {
    ...
}

public class DoubleCache implements ImageCache {
    ...
}

而ImageLoader变成这样:

public class ImageLoader {
    //缓存
    ImageCache mImageCache = new MemoryCache();
    //固定线程数的线程池,线程数为CPU的数量
    ExecutorService mExecutorService =
            Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    public void setImageCache(ImageCache imageCache) {
        mImageCache = imageCache;
    }

    public void displayImage(final String url, final ImageView imageView) {
        Bitmap bitmap = mImageCache.get(url);
        if (bitmap != null) {
            imageView.setImageBitmap(bitmap);
            return;
        }
        //图片没有缓存,提交到线程池中下载
        submitLoadRequest(url, imageView);
    }

    private void submitLoadRequest(final String url, final ImageView imageView) {
        imageView.setTag(url);
        mExecutorService.submit(new Runnable() {
            @Override
            public void run() {
                Bitmap bitmap = downloadImage(url);
                if (bitmap == null) {
                    return;
                }
                if (imageView.getTag().equals(url)) {
                    imageView.setImageBitmap(bitmap);
                }
                mImageCache.put(url, bitmap);
            }
        });
    }

    private Bitmap downloadImage(String imageUrl) {
        Bitmap bitmap = null;
        try {
            URL url = new URL(imageUrl);
            HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
            bitmap = BitmapFactory.decodeStream(connection.getInputStream());
            connection.disconnect();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return bitmap;
    }
}

这里的增加了一个setImageCache(ImageCache imageCache) 函数,用户可以通过该函数设置缓存实现,也就是通常说的依赖注入:

//默认使用内存缓存
ImageLoader imageLoader = new ImageLoader();
//使用SD卡缓存
imageLoader.setImageCache(new DiskCache());
//使用双缓存
imageLoader.setImageCache(new DoubleCache());
//使用自定义缓存
imageLoader.setImageCache(new ImageCache(){

    @Ovrride
    public void put(String url, Bitmap bitmap){
        //缓存图片
    }

    @Ovrride
    public Bitmap get(String url){
        retrun /*从缓存获取图片*/;
    }
});

现在我们再来看看,无论之后新增加什么缓存,都可以实现ImageCache接口,然后通过setImageCache方法注入,在这个过程中我们都不用修改ImageLoader类的代码,这就是开闭原则:对扩展是开放的,对修改是封闭的。而设计模式是前人总结的能更好地遵守原则的方式。

里氏替换原则

Liskov Substitution Principle

定义:如果对每一个类型为S的对象O1,都有类型为T的独享O2,使得以T定义的所有程序P在所有的对象O1都代替成O2时,程序P的行为没有发生变化,那么类型S是类型T的子类型。

看定义的话不是很好理解,换一种描述方式就是:所有引用基类的地方必须能透明地使用其子类的对象。

我们来看看Android中Window与View的关系:

Window与View的关系

Window依赖于View,而View定义了一个视图抽象,measure是各个子类共享的方法,子类通过复写View的draw方法实现具有各自特色的功能,即绘制自身内容。任何继承自View类的子类都可以设置给show方法,就是所说的里式替换。通过里式替换,就可以自定义各式各样、千变万化的View,然后传递给Window,Window负责组织View,并且将View显示到屏幕上。

里式替换的核心原理是抽象,抽象又依赖于继承这个特性,在OOP当中,继承的优缺点都相当明显:优点有以下几点:

  1. 代码重用,减少创建类的成本,每个子类都拥有父类的方法和属性;
  2. 子类与父类基本相似,但又与父类有所区别;
  3. 提高代码的可扩展性。

继承的缺点:

  1. 继承是侵入性的,只要继承就必须拥有父类的所有属性和方法;
  2. 可能造成子类代码冗余、灵活性降低,因为子类必须拥有父类的属性和方法。

事物总是具有两面性,如何权衡利弊都是需要根据具体情况来做出选择并加以处理。

前面ImageLoader的例子也体现了里式替换原则,即MemoryCache、DiskCache、DoubleCache都可以替换ImageCache的工作,并且能够保证行为的正确性。ImageCache建立获取缓存图片、保存缓存图片的接口规范,MemoryCache等根据接口规范实现了相应的功能,用户只需要在使用时指定具体的缓存对象就可以动态地替换ImageLoader中的缓存策略。这就使得ImageLoader的缓存系统具有无限的可能性,也就保证了可扩展性。

开闭原则和里式替换原则往往是生死相依,不离不弃的,通过里式替换来达到对扩展开放,对修改关闭的效果。然而,这两个原则都同时强调了一个OOP的重要特性——抽象,因此,在开发过程中运用抽象是走向代码优化的重要一步。

依赖倒置原则

Dependence Inversion Principe

定义:是一种特殊的解耦形式,使得高层次的模块不依赖于低层次的模块的实现细节的目的,依赖模块被颠倒了。
这到底是什么意思呢? 依赖倒置原则有以下几个关键点:

  • 高层模块不应该依赖低层模块,两者都应该依赖其抽象;
  • 抽象不应该依赖细节;
  • 细节应该依赖抽象。

在Java中,抽象就是指接口或抽象类,两者都是不能直接实例化的;细节就是实现类,实现接口或继承抽象类而产生的类就是细节,其特点就是,可以直接被实例化。高层模块就是调用端,低层模块就是具体实现类。依赖倒置原则在Java语言中的表现就是:模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的。通俗点说就是面对接口(抽象)编程。

如果类与类直接依赖于细节,那么他们之间就有直接的耦合,当具体实现需要变化时,意味着要同时修改该依赖者的代码,这限制了系统的可扩展性。ImageLoader的例子一开始直接依赖MemoryCache,是一个具体实现,而不是一个抽象类或者接口。这导致了我们后面修改其他缓存实现就需要修改ImageLoader类的代码。

最后版本的ImageLoader就很好的体现了依赖抽象,我们抽出的ImageCache的接口,并且定义了两个方法。而ImageLoader依赖的是抽象(ImageCache接口),而不是具体的某个实现类。当需求发生变化时,我们可以使用其他的实现替换原有的实现。

接口隔离原则

InterfaceSegregation Principe

定义:客户端不应该依赖它不需要的接口。另一种定义:类间的依赖关系应该建议在最小的接口上。接口隔离原则将非常庞大、臃肿的接口拆分成更小的和更具体的接口,这样客户将会只需要知道他们感兴趣的方法。接口隔离原则的目的是系统解开耦合,从而容易重构、更改和重新部署。

接口隔离原则说白了就是让客户端依赖的接口尽可能的小,这样说可能还是有点抽象,我们还是以一个例子来说明:

public void put(String url, Bitmap bitmap) {
        FileOutputStream fileOutputStream = null;
        try {
            fileOutputStream = new FileOutputStream(cacheDir + url);
            bitmap.compress(Bitmap.CompressFormat.PNG, 100, fileOutputStream);
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } finally {
            if (fileOutputStream != null) {
                try {
                    fileOutputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

这段代码的可读性非常差,各种try-catch嵌套都是些简单的代码,但是会严重影响代码的可读性,并且多层级的大括号很容易将代码写到错误的层级中。我们来看看如何解决这类问题。
我们可能知道Java中有一个Closeable接口,该接口标识了一个可关闭的对象,它只有一个close方法。FileOutputStream就实现这个接口。我们可以尝试写一个工具类,专门用于关闭Closeable接口的实现。

public final class CloseUtils {
    private CloseUtils() {
    }

    public static void closeQuietly(Closeable closeable) {
        if (closeable != null) {
            try {
                closeable.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

我们把这个工具类引用到上述的例子中看看效果:

public void put(String url, Bitmap bitmap) {
        FileOutputStream fileOutputStream = null;
        try {
            fileOutputStream = new FileOutputStream(cacheDir + url);
            bitmap.compress(Bitmap.CompressFormat.PNG, 100, fileOutputStream);
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } finally {
            CloseUtils.closeQuietly(fileOutputStream);
        }
    }

是不是简洁多了!而且这个closeQuietly方法可以运用到各类可关闭的对象中,保证了代码的重用性。我们可以想想,为什么close方法定义在FileOutputStream(或者它的父类)中,而是单独用一个接口承载这个方法呢?从使用者的角度来看,也就是这个CloseUtils工具类,其实只关心close方法,而不关心FileOutputStream的其他方法,如果没有这样一个Closeable接口,closeQuietly(Closeable closeable) 方法的形参就得定义成FileOutputStream,会将没有必要的其他方法暴露出来,并且其他同样拥有close方法的类无法使用closeQuietly来关闭。

想一想Android中的OnClickListener以及OnLongClickListener,虽然都是点击,但是并没有定义在一个接口中,而是分为两个,因为很多时候,用户只关心其中的一种。

迪米特原则

Law of Demeter 或者也叫做最少知识原则(Least Konwledge Principe)

定义:一个对象应该对其他对象有最少的了解。通俗点说,一个类应该对自己需要耦合或调用的类知道得最少,类的内部如何实现与调用者或者依赖者没有关系。类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。
下面我们用租房为例来讲讲迪米特原则的应用。
房间:

public class Room {
    public float area;
    public float price;

    public Room(float area, float price) {
        this.area = area;
        this.price = price;
    }

    @Override
    public String toString() {
        return "Room [area=" + area + ",price=" + price + "]";
    }
}

房间:

public class Mediator {
    List<Room> mRooms = new ArrayList<>();

    public Mediator() {
        for (int i = 0; i < 5; i++) {
            mRooms.add(new Room(14 + i, (14 + i) * 1500));
        }
    }

    public List<Room> getAllRooms() {
        return mRooms;
    }
}

租客:

public class Tenant {
    public float roomArea;
    public float roomPrice;
    public static final float diffPrice = 100.0001f;
    public static final float diffArea = 0.00001f;

    public void rentRoom(Mediator mediator) {
        List<Room> allRooms = mediator.getAllRooms();
        for (Room room : allRooms) {
            if (isSuitable(room)) {
                System.out.println("租到房间啦!" + room);
            }
        }
    }

    private boolean isSuitable(Room room) {
        return Math.abs(room.price - roomPrice) < diffPrice
                && Math.abs(room.area - roomArea) < diffArea;
    }
}

从代码中可以看到,Tenant不仅依赖了Mediator类,还需要频繁地与Room类打交道。如果把检测条件都放在Tenant类中,那么中介类的功能就被弱化了,导致Tenant与Room的耦合,因为Tenant必须知道许多关于Room的细节,当Room变化时Tenant也必须跟着变化。就像下面UML描述的那样:

既然耦合太严重了,那我们就只能解耦了。首先要明确的时,我们只和必要的类通信,即移除Tenant与Room的依赖。我们进行如下修改:

public class Mediator {
    List<Room> mRooms = new ArrayList<>();

    public Mediator() {
        for (int i = 0; i < 5; i++) {
            mRooms.add(new Room(14 + i, (14 + i) * 1500));
        }
    }

    public Room rentOut(float area, float price) {
        for (Room room : mRooms) {
            if (isSuitable(area, price, room)) {
                return room;
            }
        }
        return null;
    }

    private boolean isSuitable(float area, float price, Room room) {
        return Math.abs(room.price - price) < Tenant.diffPrice
                && Math.abs(room.area - area) < Tenant.diffArea;
    }
}
public class Tenant {
    public float roomArea;
    public float roomPrice;
    public static final float diffPrice = 100.0001f;
    public static final float diffArea = 0.00001f;

    public void rentRoom(Mediator mediator) {
        System.out.println("租到房间啦!" + mediator.rentOut(roomArea, roomPrice));
    }
}

重构后的UML图如下:

只是将对Room的判定操作移到了Mediator类中,这本应该是Mediator的职责,根据租户设定的条件查找符合要求的房子,并且将结果交给租户就可以了。租户并不需要知道太多关于Room的细节。