1. 程式人生 > >談談對4種內部類的理解,和使用場景分析

談談對4種內部類的理解,和使用場景分析

談談你對內部類的位元組碼和實戰使用場景理解

綜合技術 2017-12-01 閱讀原文 
 

讀完本文你將瞭解: [TOC]

  • 通過反編譯介紹四種內部類
  • 結合實戰介紹內部類的使用場景

背景介紹

我們做這個活動,除了要保證知識點的全面、完整,還想要讓每一篇文章都有自己的思考,儘可能的將知識點與實踐結合,努力讓讀者讀了有所收穫。每位小夥伴都有工作在身,每個知識點都需要經過 思考、學習、寫作、提交、稽核、修改、編輯、釋出等多個過程 ,所以整體下來時間就會慢一些,這裡先向各位道歉。

《Java 基礎系列》初步整理大概有 12 篇,主要內容為:

  1. 內部類
  2. 修飾符
  3. 裝箱拆箱
  4. 註解
  5. 反射
  6. 泛型
  7. 集合
  8. IO
  9. 字串
  10. 其他

這一篇我們來聊聊 內部類 。

“內部類”聽起來是非常普遍的東西,有些朋友會覺得:這個太基礎了吧,有啥好說的,你又來糊弄我。

既然你這麼自信,那就來試兩道筆試題吧!

第一道:要求使用已知的變數,在三個輸出方法中填入合適的程式碼,在控制檯輸出30,20,10。

class Outer {
            public int num = 10;
            class Inner {
                public int num = 20;
                public void show() {
                    int num = 30;
                    System.out.println(?);    //填入合適的程式碼
                    System.out.println(??);
                    System.out.println(???);
                }
            }
        }

        class InnerClassTest {
            public static void main(String[] args) {
                Outer.Inner oi = new Outer().new Inner();
                oi.show();
            }    
        }

接招,第二題: 補齊程式碼 ,要求在控制檯輸出”HelloWorld

interface Inter { 
            void show(); 
        }
        class Outer { 
            //補齊程式碼 
        }
        class OuterDemo {
            public static void main(String[] args) {
                  Outer.method().show();
              }
        }

先思考幾秒,看看這些題你能否應付得來。

在面試中常常遇到這樣的筆試題,咋一看這題很簡單,還是會有很多人答不好。根本原因是很多人對“內部類”的理解僅限於名稱。

“內部類、靜態內部類、匿名內部類”是什麼大家都清楚。但是當轉換一下思維,不僅僅為了完成功能,而是要保證整個專案架構的穩定靈活可擴充套件性,你會如何選擇呢?

這篇文章我們努力回答這些問題,也希望你可以說出你的答案。

四種內部類介紹

定義在一個類中或者方法中的類稱作為內部類。

內部類又可以細分為這 4 種:

  1. 成員內部類
  2. 區域性內部類
  3. 匿名內部類
  4. 靜態內部類

1.成員內部類

成員內部類就是最普通的內部類,它定義在一個類的內部中,就如同一個成員變數一樣。如下面的形式:

public class OutClass2 {
    private int i = 1;
    public static String str = "outclass";

    class InnerClass { // 成員內部類
        private int i = 2;

        public void innerMethod() {
            int i = 3;
            System.out.println("i=" + i);
            System.out.println("i=" + this.i);
            System.out.println("i=" + OutClass2.this.i);
            System.out.println("str=" + str);
        }
    }
}

public class TestClass {

    public static void main(String[] args) {
        //先建立外部類物件
        OutClass2 outClass = new OutClass2(); 
        //建立內部類物件
        OutClass2.InnerClass in = outClass.new InnerClass();
        //內部類物件呼叫自己的方法
        in.innerMethod();
    }
}

因為內部類依附於外部類存在,所以需要外部類的例項來建立內部類:

outClass.new InnerClass()

注意不是直接 new outClass.InnerClass() 。

成員內部類可以無條件的訪問外部類的成員屬性和成員方法(包括 private 和 static 型別的成員),這是因為在內部類中,隱式地持有了外部類的引用。

我們編譯上述的程式碼,可以看到,會生成兩個 class 檔案:

這個 OutClass2$InnerClass.class 就是內部類對應的位元組碼檔案,我們使用 AS 開啟,會自動進行反編譯:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package com.example.simon.androidlife.innerclass;

import com.example.simon.androidlife.innerclass.OutClass2;

class OutClass2$InnerClass {
    private int i;

    OutClass2$InnerClass(OutClass2 var1) {
        this.this$0 = var1;
        this.i = 2;
    }

    public void innerMethod() {
        byte var1 = 3;
        System.out.println("i=" + var1);
        System.out.println("i=" + this.i);
        System.out.println("i=" + OutClass2.access$000(this.this$0));
        System.out.println("str=" + OutClass2.str);
    }
}

可以看到,在內部類 OutClass2$InnerClass 的位元組碼中,編譯器為我們生成了一個引數為外部類物件的構造方法,這也解釋了內部類為什麼可以直接訪問外部類的內容, 因為持有外部類的引用 !

在這個不完整的反編譯位元組碼中,我們可以看到,編譯器會為內部類建立一個叫做 this$0 的物件,它是外部類的引用。

innerMethod() 中的 OutClass2.access$000(this.this$0)) 是什麼意思呢?

為了幫助內部類訪問外部類的資料,編譯器會生成這個 access

方 法 , 參 數 是 外 部 類 的 引 用 , 如 果 外 部 類 有 個 成 員 , 編 譯 器 會 生 成 多 個 a c c e s s 方法 , 

符號後面的數字會會隨著不同的宣告順序而改變,
可以理解為一種橋接方法

對比內部類的 innerMethod() 的 java 程式碼和位元組碼我們可以得出這些結論:

  • 在內部類中,直接使用變數名,會按照從方法中的區域性變數、到內部類的變數、到外部類的變數的順序訪問
  • 也就是說,如果在外部類、內部類、方法中有重名的變數/方法,編譯器會把方法中直接訪問變數的名稱修改為方法的名稱
  • 如果想在方法中強制訪問內部類的成員變數/方法,可以使用 this.i ,這裡的 this 表示當前的內部類物件
  • 如果想在方法中強制訪問外部類的成員變數/方法,可以使用 OutClass.this.i ,這裡的 OutClass.this 表示當前外部類物件

成員內部類就如同外部類的成員一樣,同樣可以被public、protected、private、預設(default)這些修飾符來修飾。

但是有一個限制是: 成員內部類不能建立靜態變數/方法 。如果我們嘗試建立,編譯器會直接 say no。

為什麼會這樣呢?

Stackoverflow 有一個回答很好:

“if you’re going to have a static method, the whole inner class has to be static. Without doing that, you couldn’t guarantee that the inner class existed when you attempted to call the static method. ”

我們知道要使用一個類的靜態成員,需要先把這個類載入到虛擬機器中,而成員內部類是需要由外部類物件 new 一個例項才可以使用,這就無法做到靜態成員的要求。

2.靜態內部類

說完成員內部類我們來看看靜態內部類。

使用 static 關鍵字修飾的內部類就是靜態內部類,靜態內部類和外部類沒有任何關係,可以看作是和外部類平級的類。

我們來反編譯個靜態內部類看看。

java 程式碼:

public class Outclass3 {

    private String name;
    private int age;

    public static class InnerStaticClass {

        private String name;

        public String getName() {
            return name;
        }

        public int getAge() {
            return new Outclass3().age;
        }
    }
}

編譯後的靜態內部類:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package com.example.simon.androidlife.innerclass;

import com.example.simon.androidlife.innerclass.Outclass3;

public class Outclass3$InnerStaticClass {
    private String name;

    public Outclass3$InnerStaticClass() {
    }

    public String getName() {
        return this.name;
    }

    public int getAge() {
        return Outclass3.access$000(new Outclass3());
    }
}

可以看到,靜態內部類很乾淨,沒有持有外部類的引用,我們要訪問外部類的成員只能 new 一個外部類的物件。

否則只能訪問外部類的 靜態屬性和靜態方法 ,同理外部類只能訪問內部類的 靜態屬性和靜態方法 。

3.區域性內部類

區域性內部類是指在程式碼塊或者方法中建立的類。

它和成員內部類的區別就是:區域性內部類的作用域只能在其所在的程式碼塊或者方法內,在其它地方是無法建立該類的物件。

public class OutClass4 {
    private String className = "OutClass";

    {
        class PartClassOne { // 區域性內部類
            private void method() {
                System.out.println("PartClassOne " + className);
            }
        }
        new PartClassOne().method();
    }

    public void testMethod() {
        class PartClassTwo { // 區域性類內部類
            private void method() {
                System.out.println("PartClassTwo " + className);
            }
        }
        new PartClassTwo().method();
    }
}

上面的程式碼中我們分別在程式碼塊和方法中建立了兩個區域性內部類,來看看編譯後的它是怎麼樣的:

首先可以看到會建立兩個 class 類,開啟看下:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package com.example.simon.androidlife.innerclass;

import com.example.simon.androidlife.innerclass.OutClass4;

class OutClass4$1PartClassOne {
    OutClass4$1PartClassOne(OutClass4 var1) {
        this.this$0 = var1;
    }

    private void method() {
        System.out.println("PartClassOne " + OutClass4.access$000(this.this$0));
    }
}

package com.example.simon.androidlife.innerclass;

import com.example.simon.androidlife.innerclass.OutClass4;

class OutClass4$1PartClassTwo {
    OutClass4$1PartClassTwo(OutClass4 var1) {
        this.this$0 = var1;
    }

    private void method() {
        System.out.println("PartClassTwo " + OutClass4.access$000(this.this$0));
    }
}

可以看到生成的這兩個位元組碼和成員內部類生成的很相似,都持有了外部類的引用。

不過可惜的是出了它們宣告的作用域,就再也無法訪問它們, 可以把區域性內部類理解為作用域很小的成員內部類。

4.匿名內部類

先讓我們來看一段最常見的程式碼

Car jeep=new Car();

在Java中 操縱的識別符號實際是指向一個物件的引用 ,也就是說 jeep 是一個指向 Car 類物件的引用,而右面的 new Car() 才是真正建立物件的語句。

這可以將 jeep 抽象的理解為 Car 類物件的“名字”,而匿名內部類顧名思義可以抽象的理解為沒有“名字”的內部類:

button.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
    // TODO Auto-generated method stub
    }
});

上面程式碼是 Android 中最常見的設定 button 的點選事件,其中 new OnClickListener() {…} 就是一個匿名內部類,在這裡沒有建立類物件的引用,而是直接建立的類物件。大部分匿名類用於介面回撥。

由於 javac 無法編譯 android 程式碼,我們寫個這樣的匿名內部類程式碼來嘗試看看編譯後的結果。

public class OutClass5 {
    private OnClickListener mClickListener;
    private OutClass5 mOutClass5;

    interface OnClickListener {
        void onClick();
    }

    public OutClass5 setClickListener(final OnClickListener clickListener) {
        mClickListener = clickListener;
        return this;
    }

    public OutClass5 setOutClass5(final OutClass5 outClass5) {
        mOutClass5 = outClass5;
        return this;
    }

    public void setClickInfo(final String info, int type) {
        setClickListener(new OnClickListener() {
            @Override
            public void onClick() {
                System.out.println("click " + info);
            }
        });

        setClickListener(new OnClickListener() {
            @Override
            public void onClick() {
                System.out.println("click2 " + info);
            }
        });
    }
}

上面的程式碼中,我們建立了一個內部介面,然後在 setDefaultClicker() 中建立了兩個匿名內部類,編譯後的結果:

可以看到生成了三個額外的類, OutClass5$OnClickListener 是生成的成員內部類位元組碼,而 OutClass5$1 和 OutClass5$2 則是兩個實現 OnClickListener 的子類:

class OutClass5$1 implements OnClickListener {
    OutClass5$1(OutClass5 var1, String var2) {
        this.this$0 = var1;
        this.val$info = var2;
    }

    public void onClick() {
        System.out.println("click " + this.val$info);
    }
}
class OutClass5$2 implements OnClickListener {
    OutClass5$2(OutClass5 var1, String var2) {
        this.this$0 = var1;
        this.val$info = var2;
    }

    public void onClick() {
        System.out.println("click2 " + this.val$info);
    }
}

從反編譯的程式碼可以看出: 建立的每個匿名內部類編譯器都對應生成一個實現介面的子類,同時建立一個建構函式,建構函式的引數是外部類的引用,以及匿名函式中訪問的引數 。

現在我們知道了:匿名內部類也持有外部類的引用。

同時也理解了為什麼匿名內部類不能有構造方法,只能有初始化程式碼塊。 因為編譯器會幫我們生成一個構造方法然後呼叫。

此外還可以看出,匿名內部類中使用到的引數是需要宣告為 final 的,否則編譯器會報錯。

可能有朋友會提問了:引數為什麼需要是 final 的?

我們知道在 Java 中實際只有一種傳遞方式:即引用傳遞。一個物件引用被傳遞給方法時,方法中會建立一份本地臨時引用,它和引數指向同一個物件,但卻是不同的,所以你在方法內部修改引數的內容,在方法外部是不會感知到的。

而匿名內部類是建立一個物件並返回,這個物件的方法被呼叫的時機不確定,方法中有修改引數的可能,如果在匿名內部類中修改了引數,外部類中的引數是否需要同步修改呢?

因此,JAVA 為了避免這種問題,限制匿名內部類訪問的變數需要使用 FINAL 修飾,這樣可以保證訪問的變數不可變。

內部類的使用場景

上面介紹了 Java 中 4 種內部類的定義,接著我們介紹這些內部類的一些使用場景。

1.成員內部類的使用場景

普通內部類可以訪問外部類的所有成員和方法,因此當類 A 需要使用類 B ,同時 B 需要訪問 A 的成員/方法時,可以將 B 作為 A 的成員內部類。

比如安卓開發中常見的在一個 Activity 中有一個 ListView ,我們需要建立一個特定業務的 adapter,在這個 adapter 中需要傳入資料,你可以另建一個類,但如果只有當前類需要使用到,完全可以將它建立在 Activity 中:

public class VideoListActivity extends AppCompatActivity{
    private ListView mVideoListView;
    private BaseAdapter mListAdapter;
    private List mVideoInfoData;

    @Override
    protected void onCreate(@Nullable final Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_video_list);
        mVideoListView = (ListView) findViewById(R.id.video_list);
        mVideoInfoData = Collections.EMPTY_LIST;
        mListAdapter = new VideoListAdapter();
        mVideoListView.setAdapter(mListAdapter);
    }

    //這裡的 private 內部類說明這個 adapter 只能在當前類中使用
    private class VideoListAdapter extends BaseAdapter {

        @Override
        public int getCount() {
            return mVideoInfoData.size();   //訪問外部類資料
        }

        @Override
        public Object getItem(final int position) {
            return mVideoInfoData.get(position);    //訪問外部類資料
        }

        @Override
        public long getItemId(final int position) {
            return 0;
        }

        @Override
        public View getView(final int position, final View convertView, final ViewGroup parent) {
            return null;
        }
    }
}

這是一種簡單的使用場景。

在 Java 中普通類(非內部類)是不可以設為 private 或者 protected ,只能設定成 public default 。

而內部類則可以,因此我們 可以利用 private 內部類禁止其他類訪問該內部類,從而做到將具體的實現細節完全隱藏。

比如我們有一個 Activity 既可以用作登入也可以用作註冊,我們可以這樣寫:

public class MultiplexViewActivity extends AppCompatActivity {
    public static final String DATA_VIEW_TYPE = "view_type";
    public static final int TYPE_LOGIN = 1;
    public static final int TYPE_REGISTER = 2;

    private TextView mTitleTv;
    private ViewController mViewController;

    @Override
    protected void onCreate(@Nullable final Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_multiplex_view);

        int type = getIntent().getIntExtra(DATA_VIEW_TYPE, TYPE_LOGIN);
        mViewController = getViewController(type);

        initView();
    }

    //外界只能拿到基類,具體實現隱藏
   public ViewController getViewController(final int type) {
        switch (type) {
            case TYPE_REGISTER:
                return new RegisterViewController();
            case TYPE_LOGIN:
            default:
                return new LoginViewController();
        }
    }

    private void initView() {
        mTitleTv = (TextView) findViewById(R.id.multiplex_title_tv);
        mViewController.initUi();
    }

    /**
     * 定義操作規範
     */
    private interface ViewController {
        void initUi();

        void loadData();
    }

    private class LoginViewController implements ViewController {
        @Override
        public void initUi() {
            mTitleTv.setText("登入");
            //顯示登入需要的佈局
        }

        @Override
        public void loadData() {
            //載入登入需要的資料
        }
    }

    private class RegisterViewController implements ViewController {
        @Override
        public void initUi() {
            mTitleTv.setText("註冊");
            //顯示註冊需要的佈局
        }

        @Override
        public void loadData() {
            //載入註冊需要的資料
        }
    }
}

解釋一下上面的程式碼,由於要複用這個佈局,所以先定義一個佈局控制介面 ViewController ,再建立兩個內部類實現介面,分別負責登入和註冊的佈局控制和資料載入。

然後提供一個方法根據引數獲取具體的控制器實現 getViewController(final int type) ,這個方法可以是 public 的,外界即使拿到這個 activity 例項,也只能獲取到佈局控制器基類,具體的實現被隱藏了,這在後期修改某一個頁面時,不用擔心會對其他地方造成影響。

有朋友可能會說了:“這 2 個內部類也可以定義成普通類呀”。

確實普通類也同樣能滿足需求,但是我們希望這 2 個類只是在這個公共支付資訊頁面才用到,在外界看來是不可見或不可用的狀態,這個時候內部類就能滿足我們的需求。

這樣的場景在 簡單工廠模式、迭代器設計模式、命令設計模式都有用到,有興趣的朋友可以去了解下。

2.靜態內部類的使用場景

靜態內部類只能訪問外部類的靜態變數和方法,但相對普通內部類的功能更為完整,因為它 可以定義靜態變數/方法 。

當類 A 需要使用類 B,而 B 不需要直接訪問外部類 A 的成員變數和方法時,可以將 B 作為 A 的靜態內部類。

比較常見的一種使用場景是: 在基類 A 裡持有靜態內部類 B 的引用,然後在 A 的子類裡建立特定業務的 B 的子類,這樣就結合多型和靜態內部類的優勢,既能拓展,又能限制範圍 。

我們經常使用的 LayoutParams 就是靜態內部類,由於不同的佈局中引數不一樣,Android SDK 提供了很多種 LayoutParams:

  • ViewGroup.LayoutParams
  • WindowManager.LayoutParams 繼承上一層
  • RelativeLayout.LayoutParams
public interface WindowManager extends ViewManager {

    //...
    public static class LayoutParams extends ViewGroup.LayoutParams implements Parcelable {
    //...
    }
}

在 View 的 setLayoutParams 中的引數型別是最上層的 ViewGroup.LayoutParams params ,這樣子類就可以傳入符合自己特性的 LayoutParams 實現:

public void setLayoutParams(ViewGroup.LayoutParams params) {
    if (params == null) {
        throw new NullPointerException("Layout parameters cannot be null");
    }
    mLayoutParams = params;
    resolveLayoutParams();
    if (mParent instanceof ViewGroup) {
        ((ViewGroup) mParent).onSetLayoutParams(this, params);
    }
    requestLayout();
}

靜態內部類的另一種使用場景是: 實現單例模式 。

記得有一年去點評面試,面試官讓我寫個靜態內部類實現的單例模式,我寫的過程中不確定靜態內部類是否可以有靜態成員,基礎有多差可想而知。

先來看一下如何實現:

public class LocationManager{
    private static class ClassHolder {
        private static final LocationManager instance = new LocationManager();
    }
    public static LocationManager getInstance() {
        return ClassHolder.instance;
    }
}

我們知道靜態內部類功能和普通類一致,所以有 static 成員不足為奇。現在的問題是,為什麼這種單例模式比較好?

原因有兩點:

  1. 懶載入:類載入時不會建立例項,只有當 getInstance() 方法被呼叫時才去載入靜態內部類以及其中持有的 LocationManager 例項
  2. 執行緒安全:JVM 載入類時,可以確保 instance 變數只能初始化一次

3.匿名內部類的使用場景

Android 開發中設定一個按鈕的點選事件很簡單,直接 new 一個 View.OnClickListener 然後實現方法即可:

mButton2.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                    //...
            }
        });

結合前面談到的,編譯器會為每個匿名內部類建立一個 Class 檔案。個人覺得在安卓開發中,有多個按鈕需要設定點選事件時,讓當前類實現 OnClickListener 介面然後在 onClick() 中根據 id 判斷事件,比建立一大堆匿名內部類要好些,你覺得呢?

之所以這樣寫,是因為我們不需要持有這個 new View.OnClickListener 的引用,只要建立了物件即可。

所以使用場景可以是:一個方法的返回值是介面,然後根據不同引數返回不同的實現,我們不需要儲存引用,直接 new 一個介面實現即可。

來看一個有趣的例子:

public class GirlFriendMaker {
    public interface GirlFriend {
        void sayHi();
    }

    public static GirlFriend giveMeAGirlFriend(final String name) {
        return new GirlFriend() {    //匿名內部類
            @Override
            public void sayHi() {
                Log.i("來自女朋友的問候", "Hello I'm " + name);
            }
        };
    }
}

4.區域性內部類

區域性內部類只用於當前方法或者程式碼塊中建立、使用,一次性產品,使用場景比較少。

記憶體洩漏

經過前面的介紹我們知道,四種內部類中除了靜態內部類,只要訪問外部類的成員/方法,就會持有外部類的引用。

當內部類持有外部類的引用,同時生命週期比外部類要長(比如執行耗時任務、被其他長生命週期物件持有),就會導致外部類該被回收時無法被回收,也就是記憶體洩漏問題。

一個 Android 開發中常見的內部類導致記憶體洩露的例子:

public class MainActivity extends AppCompatActivity {

    public final int LOGIN_SUCCESS = 1;

    private Context mContext;
    private boolean isLongTimeNoMsg;


    @SuppressWarnings("HandlerLeak")
    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            isLongTimeNoMsg = false;
            switch (msg.what) {
                case LOGIN_SUCCESS: {/
                    break;
                }
                //...
    }
}

這個 Handler 持有外部類的引用,它傳送的 runnable 物件,會被進一步包裝為 message 物件,放入訊息佇列,在被執行、回收之前會一致持有引用,導致無法釋放。

解決辦法就是使用弱引用或者乾脆將 Handler 設計為靜態內部類。

總結

總的來說,內部類一般用於兩個場景:

  1. 需要用一個類來解決一個複雜的問題,但是又不希望這個類是公共的
  2. 需要實現一個介面,但不需要持有它的引用

本篇文章介紹了 Java 開發中四種內部類的概念、反編譯後的格式以及使用場景。相信看完這篇文章,你對開頭的兩道題已經有了答案。

基礎就是這樣,不論你走的多遠,都需要及時回顧、彌補,等工作中需要用到才補,會錯失很多機會。

這個系列的目的是幫助大家系統、完整的打好基礎、逐漸深入學習,如果你對這些已經很熟了,請不要吝嗇你的評價,多多指出問題,我們一起做的更好!

文章同步傳送於微信公眾號:安卓進化論,歡迎關注,第一時間獲取新文章。