วิธีการ Parse JSON ขนาดใหญ่ใน Android

ยามเช้าในวันที่อากาศไม่ค่อยจะสดใสวันหนึ่ง ผมได้รับแจ้งจาก User ผู้ใช้งานแอป miimai ว่าเกิดปัญหาในการใช้งานบางอย่างขึ้น และได้ Report ข้อผิดพลาดมาให้ผมเรียบร้อยแล้ว ผมก็เลยรีบไปเปิดดูข้อมูลในทันใดและพบว่าเป็น OutOfMemoryError ตอนที่เห็นทีแรกยังค่อยข้างงงอยู่บ้างว่ามันเป็นไปได้ยังไง ปกติแล้วแอปแทบจะไม่ใช้ Memory เลยด้วยซ้ำ

ผมค่อยๆ ไล่ดูบรรทัดที่เกิดปัญหาและพบว่ามันมาจากบรรทัดนี้

new JSONObject(resposeStr)

Constructor ของ JSON จะทำการรับข้อมูล JSON ที่อยู่ในรูปของ String (ผมแปลงเป็น String ก่อนที่จะส่งข้อมูลมา) มาแปลงให้อยู่ในรูปของ Object แบบ JSON ซึ่งดูผ่านๆ ก็ไม่ได้มีปัญหาอะไรตรงไหน

ปัญหาที่ผมเจอมันอยู่ตรงที่ว่า เครื่องของ User เป็นเครื่องเก่าและ Memory ต่ำพอสมควร ประกอบกับข้อมูลที่ผมส่งไปเป็นข้อมูลหนังสือประมาณ 20,000 กว่าเล่ม (รวมๆ แล้วก็หลาย MB อยู่) ซึ่งการแปลง String ให้เป็น Object นี้จะแปลง String ทั้งหมดเลย และใช้ Memory สูงมาก (หลายคนบอกว่าน่าจะประมาณ 3–5 เท่าของขนาดเดิม)

โอเคครับ รับทราบปัญหากันเป็นที่เรียบร้อยแล้ว คำถามถัดไปที่ควรจะถามคือ จะแก้ยังไงดี

ผมลองหาข้อมูลดู ก็มีบางคนบอกว่า จริงๆ แล้ว JSON Protocol เนี่ยไม่ควรนำมาใช้ในการส่งข้อมูลจำนวนมากๆ (เพราะมัน Parse แล้ว Memory บวมอย่างที่เห็น) ควรจะใช้วิธีการอื่นมากกว่า

ซึ่งก็ดูมีเหตุผลนะ แต่ในเมื่อแอปมันออกแบบมาแบบนี้แล้ว ถ้าแก้ทีนี่ยาวเลยนะ มีวิธีไหนมั้ยที่ยังใช้ JSON อยู่ แต่ไม่ให้มันใช้หน่วยความจำเกินแบบนี้อีก ซึ่งพอลองหาๆ ไปก็เจอว่าวิธีการคือค่อยๆ Parse ทีละส่วน (หลายที่เรียกมันว่า Streaming) มันก็จะลดการใช้หน่วยความจำจำนวนมากในคราวเดียวได้ โดยไลบลาลีที่สามารถนำมาใช้ได้ก็อย่างเช่น Jackson และ GSON

ด้วยอคติส่วนตัว ผมจึงลองเลือก GSON มาใช้อย่างไม่มีเหตุผลใดๆ

วิธีการใช้หลักๆ ก็คือเราต้องเขียนให้มัน Parse ข้อมูลเอาเองว่าจะมีข้อมูลอะไรยังไงบ้าง เช่นสมมติว่าข้อมูลของผมเป็นหนังสือหลายๆ เล่มแล้วกัน

[
  {
    id: 20,
    name: "Dragon Ball",
    author: "TORIYAMA Akira"
  },
  {
    ...
  },
  ...
]

ก็ให้สร้างฟังก์ชันสำหรับ Parse ตัวข้อมูลหนังสือขึ้นมาก่อน โดยมีพารามิเตอร์เป็น JsonReader

public Book readBook(JsonReader reader) throws IOException {
    Book book = new Book();

    reader.beginObject();
    while (reader.hasNext()) {
        String name = reader.nextName();
        switch (name) {
            case "id":
                book.setId(reader.nextInt()); break;
            case "name":
                book.setName(reader.nextString()); break;
            case "author":
                book.setAuthor(reader.nextString()); break;
            default:
                reader.skipValue();
        }
    }
    reader.endObject();
    return book;
}

ฟังก์ชันนี้ก็จะมีหน้าที่สำหรับ Parse ข้อมูลหนังสือแต่ละเล่มโดยเฉพาะ จากนั้นก็สร้างอีกฟังก์ชันสำหรับ Parse ข้อมูลอาร์เรย์ของหนังสือ

public List readBookArray(JsonReader reader) throws  IOException {
    List books = new ArrayList<>();

    reader.beginArray();
    while (reader.hasNext()) {
        books.add(readBook(reader));
    }
    reader.endArray();
    return books;
}

อารมณ์ก็ง่ายๆ ประมาณนี้แหละครับ เหลือแค่แปลง String ให้เป็น JsonReader แล้วก็ส่งเข้ามาเท่านั้นเอง

InputStream stream = new ByteArrayInputStream(response.getBytes(Charset.forName("UTF-8")));
JsonReader reader = new JsonReader(new InputStreamReader(in, "UTF-8"));

เท่านี้ก็เป็นอันเรียบร้อยครับ ผมก็เลยลองเทสดูด้วยว่ามันใช้ Memory ต่างกันจริงๆ รึเปล่า เริ่มจากวิธีแรก

    

วิธีนี้คือใช้ JSONObject แบบตรงๆ เลย ถ้าดูก็จะเห็นว่ามันกิน Memory เยอะอยู่แป๊ปเดียวก็จริง แต่ก็เพียงพอให้ Error แล้วครับ ช่วงพีคสุดคือกินไป 50 กว่าๆ MB

ทีนี้ผมลองใช้ GSON แล้ว Parse แบบเองดู

  

กินสูงสุดเหลือแค่ 30 กว่า MB เอง ใช้ได้เลยแฮะ

ถ้าอยากลองใช้กันดู ก็ไปดูรายละเอียดที่ https://github.com/google/gson เลยนะครับ น่าจะละเอียดกว่าผมอธิบายแน่ๆ

Taxonomy upgrade extras: