In this post I will share an example of analyzing memory leak in an Android application. I recently tried to integrate a popular android library ShowcaseView which can be used for first run demos in your android application. The thing is while testing the library, I noticed severe memory leaks. This was occurring because references to ShowcaseView class were being kept. And, as bitmaps are created internally by this class to overlay the content, so each instance was holding a large chunk of memory.
Analyzing the issue
For heap analysis, I used VisualVM. First, thing you need to realize is that the heap dump taken from android cannot be directly analyzed with VisualVM or other tools such as eclipse MAT. You need to convert it to a format that these tools can analyze. For this you have to run the following command.
hprof-conv Snapshot_2015.04.10_19.37.29.hprof heapissue
Here the first parameter is the android heap dump and the second is the output file.
After loading the file into VisualVM for analysis, I could see the references of ShowCaseView class were being retained as it had GC roots, thus live references, which meant that the instances could not be collected by the JVM Garbage collector.
Simple OQL analysis revealed live paths to the instances.
Query:
select heap.livepaths(u,false) from com.github.amlcurran.showcaseview.ShowcaseView u
As you can see phone decor view was holding references to the view which was added by ShowCaseView. Thus, the decorview was holding reference to ShowCaseView instances.
Now, Ideally whenever the view has been displayed it should be destroyed, especially, if it is holding such large chunks of memory. but, instead due to oversight from the developer instead of removing the view from decorview, he was just setting the views property to View.Gone, which only makes the view invisible and does not remove the view from the ViewGroup.
The Solution
The problem was then clearly with the fact that the added views should be removed after the ShowcaseView was hidden. So, I just modified the hide() and hideImmediate() methods to remove the views that were added on top of the decorView.
I have mentioned one of those method below.
@Override
public void hide() {
clearBitmap();
// If the type is set to one-shot, store that it has shot
shotStateStore.storeShot();
mEventListener.onShowcaseViewHide(this);
fadeOutShowcase();
getViewTreeObserver().removeOnPreDrawListener(draw);
if(Build.VERSION.SDK_INT>15){
getViewTreeObserver().removeOnGlobalLayoutListener(globalLayout);
}
else {
getViewTreeObserver().removeGlobalOnLayoutListener(globalLayout);
}
// removeView(ShowcaseView.this);
((ViewGroup)mActivity.getWindow().getDecorView()).removeView(ShowcaseView.this);
}
Post, implementing these changes the library works as it is supposed to work, that is just fine.
Below, I have mentioned the Gist that contains the entire source code for the modified ShowcaseView. So, happy coding :-)
/* | |
* Copyright 2014 Alex Curran | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
*/ | |
package com.github.amlcurran.showcaseview; | |
import android.app.Activity; | |
import android.content.Context; | |
import android.content.res.TypedArray; | |
import android.graphics.Bitmap; | |
import android.graphics.Canvas; | |
import android.graphics.Color; | |
import android.graphics.Point; | |
import android.graphics.PorterDuff; | |
import android.os.Build; | |
import android.text.TextUtils; | |
import android.util.AttributeSet; | |
import android.view.LayoutInflater; | |
import android.view.MotionEvent; | |
import android.view.View; | |
import android.view.ViewGroup; | |
import android.view.ViewTreeObserver; | |
import android.widget.Button; | |
import android.widget.RelativeLayout; | |
import com.github.amlcurran.showcaseview.targets.Target; | |
import static com.github.amlcurran.showcaseview.AnimationFactory.AnimationEndListener; | |
import static com.github.amlcurran.showcaseview.AnimationFactory.AnimationStartListener; | |
/** | |
* A view which allows you to showcase areas of your app with an explanation. | |
*/ | |
public class ShowcaseView extends RelativeLayout | |
implements View.OnTouchListener, ShowcaseViewApi { | |
private static final int HOLO_BLUE = Color.parseColor("#33B5E5"); | |
private final Button mEndButton; | |
private final TextDrawer textDrawer; | |
private final ShowcaseDrawer showcaseDrawer; | |
private final ShowcaseAreaCalculator showcaseAreaCalculator; | |
private final AnimationFactory animationFactory; | |
private final ShotStateStore shotStateStore; | |
// Showcase metrics | |
private int showcaseX = -1; | |
private int showcaseY = -1; | |
private float scaleMultiplier = 1f; | |
private CalculateTextOnPreDraw draw=null; | |
private UpdateOnGlobalLayout globalLayout=null; | |
// Touch items | |
private boolean hasCustomClickListener = false; | |
private boolean blockTouches = true; | |
private boolean hideOnTouch = false; | |
private OnShowcaseEventListener mEventListener = OnShowcaseEventListener.NONE; | |
private boolean hasAlteredText = false; | |
private boolean hasNoTarget = false; | |
private boolean shouldCentreText; | |
private Bitmap bitmapBuffer; | |
private Activity mActivity=null; | |
// Animation items | |
private long fadeInMillis; | |
private long fadeOutMillis; | |
private boolean isShowing; | |
public ShowcaseView(Context context, boolean newStyle) { | |
this(context, null, R.styleable.CustomTheme_showcaseViewStyle, newStyle); | |
if(context instanceof Activity){ | |
mActivity=(Activity)context; | |
} | |
} | |
public ShowcaseView(Context context, AttributeSet attrs, int defStyle, boolean newStyle) { | |
super(context, attrs, defStyle); | |
if(context instanceof Activity){ | |
mActivity=(Activity)context; | |
} | |
ApiUtils apiUtils = new ApiUtils(); | |
animationFactory = new AnimatorAnimationFactory(); | |
showcaseAreaCalculator = new ShowcaseAreaCalculator(); | |
shotStateStore = new ShotStateStore(context); | |
apiUtils.setFitsSystemWindowsCompat(this); | |
draw=new CalculateTextOnPreDraw(); | |
getViewTreeObserver().addOnPreDrawListener(draw); | |
globalLayout=new UpdateOnGlobalLayout(); | |
getViewTreeObserver().addOnGlobalLayoutListener(globalLayout); | |
// Get the attributes for the ShowcaseView | |
final TypedArray styled = context.getTheme() | |
.obtainStyledAttributes(attrs, R.styleable.ShowcaseView, R.attr.showcaseViewStyle, | |
R.style.ShowcaseView); | |
// Set the default animation times | |
fadeInMillis = getResources().getInteger(android.R.integer.config_mediumAnimTime); | |
fadeOutMillis = getResources().getInteger(android.R.integer.config_mediumAnimTime); | |
mEndButton = (Button) LayoutInflater.from(context).inflate(R.layout.showcase_button, null); | |
if (newStyle) { | |
showcaseDrawer = new NewShowcaseDrawer(getResources()); | |
} else { | |
showcaseDrawer = new StandardShowcaseDrawer(getResources()); | |
} | |
textDrawer = new TextDrawer(getResources(), showcaseAreaCalculator, getContext()); | |
updateStyle(styled, false); | |
init(); | |
} | |
private void init() { | |
setOnTouchListener(this); | |
if (mEndButton.getParent() == null) { | |
int margin = (int) getResources().getDimension(R.dimen.button_margin); | |
RelativeLayout.LayoutParams lps = (LayoutParams) generateDefaultLayoutParams(); | |
lps.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); | |
lps.addRule(RelativeLayout.ALIGN_PARENT_RIGHT); | |
lps.setMargins(margin, margin, margin, margin); | |
mEndButton.setLayoutParams(lps); | |
mEndButton.setText(android.R.string.ok); | |
if (!hasCustomClickListener) { | |
mEndButton.setOnClickListener(hideOnClickListener); | |
} | |
addView(mEndButton); | |
} | |
} | |
public boolean hasShot() { | |
return shotStateStore.hasShot(); | |
} | |
void setShowcasePosition(Point point) { | |
setShowcasePosition(point.x, point.y); | |
} | |
void setShowcasePosition(int x, int y) { | |
if (shotStateStore.hasShot()) { | |
return; | |
} | |
showcaseX = x; | |
showcaseY = y; | |
//init(); | |
invalidate(); | |
} | |
public void setTarget(final Target target) { | |
setShowcase(target, false); | |
} | |
public void setShowcase(final Target target, final boolean animate) { | |
postDelayed(new Runnable() { | |
@Override | |
public void run() { | |
if (!shotStateStore.hasShot()) { | |
updateBitmap(); | |
Point targetPoint = target.getPoint(); | |
if (targetPoint != null) { | |
hasNoTarget = false; | |
if (animate) { | |
animationFactory.animateTargetToPoint(ShowcaseView.this, targetPoint); | |
} else { | |
setShowcasePosition(targetPoint); | |
} | |
} else { | |
hasNoTarget = true; | |
invalidate(); | |
} | |
} | |
} | |
}, 100); | |
} | |
private void updateBitmap() { | |
if (bitmapBuffer == null || haveBoundsChanged()) | |
{ | |
if(bitmapBuffer != null) { | |
bitmapBuffer.recycle(); | |
bitmapBuffer=null; | |
} | |
bitmapBuffer = Bitmap.createBitmap(getMeasuredWidth() > 0 ? getMeasuredWidth() : 16, | |
getMeasuredHeight() > 0 ? getMeasuredHeight() : 16, | |
Bitmap.Config.ARGB_8888); | |
// bitmapBuffer = Bitmap.createBitmap(getMeasuredWidth(), getMeasuredHeight(), Bitmap.Config.ARGB_8888); | |
} | |
} | |
private boolean haveBoundsChanged() { | |
return getMeasuredWidth() != bitmapBuffer.getWidth() || | |
getMeasuredHeight() != bitmapBuffer.getHeight(); | |
} | |
public boolean hasShowcaseView() { | |
return (showcaseX != 1000000 && showcaseY != 1000000) && !hasNoTarget; | |
} | |
public void setShowcaseX(int x) { | |
setShowcasePosition(x, showcaseY); | |
} | |
public void setShowcaseY(int y) { | |
setShowcasePosition(showcaseX, y); | |
} | |
public int getShowcaseX() { | |
return showcaseX; | |
} | |
public int getShowcaseY() { | |
return showcaseY; | |
} | |
/** | |
* Override the standard button click event | |
* | |
* @param listener Listener to listen to on click events | |
*/ | |
public void overrideButtonClick(OnClickListener listener) { | |
if (shotStateStore.hasShot()) { | |
hide(); | |
return; | |
} | |
if (mEndButton != null) { | |
if (listener != null) { | |
mEndButton.setOnClickListener(listener); | |
} else { | |
mEndButton.setOnClickListener(hideOnClickListener); | |
} | |
} | |
hasCustomClickListener = true; | |
} | |
public void setOnShowcaseEventListener(OnShowcaseEventListener listener) { | |
if (listener != null) { | |
mEventListener = listener; | |
} else { | |
mEventListener = OnShowcaseEventListener.NONE; | |
} | |
} | |
public void setButtonText(CharSequence text) { | |
if (mEndButton != null) { | |
mEndButton.setText(text); | |
} | |
} | |
private void recalculateText() { | |
boolean recalculatedCling = showcaseAreaCalculator.calculateShowcaseRect(showcaseX, showcaseY, showcaseDrawer); | |
boolean recalculateText = recalculatedCling || hasAlteredText; | |
if (recalculateText) { | |
textDrawer.calculateTextPosition(getMeasuredWidth(), getMeasuredHeight(), this, shouldCentreText); | |
} | |
hasAlteredText = false; | |
} | |
@SuppressWarnings("NullableProblems") | |
@Override | |
protected void dispatchDraw(Canvas canvas) { | |
if (showcaseX < 0 || showcaseY < 0 || shotStateStore.hasShot() || bitmapBuffer == null) { | |
super.dispatchDraw(canvas); | |
return; | |
} | |
//Draw background color | |
showcaseDrawer.erase(bitmapBuffer); | |
// Draw the showcase drawable | |
if (!hasNoTarget) { | |
showcaseDrawer.drawShowcase(bitmapBuffer, showcaseX, showcaseY, scaleMultiplier); | |
showcaseDrawer.drawToCanvas(canvas, bitmapBuffer); | |
} | |
// Draw the text on the screen, recalculating its position if necessary | |
textDrawer.draw(canvas); | |
super.dispatchDraw(canvas); | |
} | |
@Override | |
public void hide() { | |
clearBitmap(); | |
// If the type is set to one-shot, store that it has shot | |
shotStateStore.storeShot(); | |
mEventListener.onShowcaseViewHide(this); | |
fadeOutShowcase(); | |
getViewTreeObserver().removeOnPreDrawListener(draw); | |
if(Build.VERSION.SDK_INT>15){ | |
getViewTreeObserver().removeOnGlobalLayoutListener(globalLayout); | |
} | |
else { | |
getViewTreeObserver().removeGlobalOnLayoutListener(globalLayout); | |
} | |
// removeView(ShowcaseView.this); | |
((ViewGroup)mActivity.getWindow().getDecorView()).removeView(ShowcaseView.this); | |
} | |
private void clearBitmap() { | |
if (bitmapBuffer != null && !bitmapBuffer.isRecycled()) { | |
bitmapBuffer.recycle(); | |
bitmapBuffer = null; | |
} | |
} | |
private void fadeOutShowcase() { | |
animationFactory.fadeOutView(this, fadeOutMillis, new AnimationEndListener() { | |
@Override | |
public void onAnimationEnd() { | |
setVisibility(View.GONE); | |
isShowing = false; | |
mEventListener.onShowcaseViewDidHide(ShowcaseView.this); | |
} | |
}); | |
} | |
@Override | |
public void show() { | |
isShowing = true; | |
mEventListener.onShowcaseViewShow(this); | |
fadeInShowcase(); | |
} | |
private void fadeInShowcase() { | |
animationFactory.fadeInView(this, fadeInMillis, | |
new AnimationStartListener() { | |
@Override | |
public void onAnimationStart() { | |
setVisibility(View.VISIBLE); | |
} | |
} | |
); | |
} | |
@Override | |
public boolean onTouch(View view, MotionEvent motionEvent) { | |
float xDelta = Math.abs(motionEvent.getRawX() - showcaseX); | |
float yDelta = Math.abs(motionEvent.getRawY() - showcaseY); | |
double distanceFromFocus = Math.sqrt(Math.pow(xDelta, 2) + Math.pow(yDelta, 2)); | |
if (MotionEvent.ACTION_UP == motionEvent.getAction() && | |
hideOnTouch && distanceFromFocus > showcaseDrawer.getBlockedRadius()) { | |
this.hide(); | |
return true; | |
} | |
return blockTouches && distanceFromFocus > showcaseDrawer.getBlockedRadius(); | |
} | |
private static void insertShowcaseView(ShowcaseView showcaseView, Activity activity) { | |
((ViewGroup) activity.getWindow().getDecorView()).addView(showcaseView); | |
if (!showcaseView.hasShot()) { | |
showcaseView.show(); | |
} else { | |
showcaseView.hide(); | |
} | |
} | |
public void hideImmediate() { | |
isShowing = false; | |
setVisibility(GONE); | |
// removeView(ShowcaseView.this); | |
((ViewGroup)mActivity.getWindow().getDecorView()).removeView(ShowcaseView.this); | |
} | |
@Override | |
public void setContentTitle(CharSequence title) { | |
textDrawer.setContentTitle(title); | |
} | |
@Override | |
public void setContentText(CharSequence text) { | |
textDrawer.setContentText(text); | |
} | |
private void setScaleMultiplier(float scaleMultiplier) { | |
this.scaleMultiplier = scaleMultiplier; | |
} | |
public void hideButton() { | |
mEndButton.setVisibility(GONE); | |
} | |
public void showButton() { | |
mEndButton.setVisibility(VISIBLE); | |
} | |
/** | |
* Builder class which allows easier creation of {@link ShowcaseView}s. | |
* It is recommended that you use this Builder class. | |
*/ | |
public static class Builder { | |
final ShowcaseView showcaseView; | |
private final Activity activity; | |
public Builder(Activity activity) { | |
this(activity, false); | |
} | |
public Builder(Activity activity, boolean useNewStyle) { | |
this.activity = activity; | |
this.showcaseView = new ShowcaseView(activity, useNewStyle); | |
this.showcaseView.setTarget(Target.NONE); | |
} | |
/** | |
* Create the {@link com.github.amlcurran.showcaseview.ShowcaseView} and show it. | |
* | |
* @return the created ShowcaseView | |
*/ | |
public ShowcaseView build() { | |
insertShowcaseView(showcaseView, activity); | |
return showcaseView; | |
} | |
/** | |
* Set the title text shown on the ShowcaseView. | |
*/ | |
public Builder setContentTitle(int resId) { | |
return setContentTitle(activity.getString(resId)); | |
} | |
/** | |
* Set the title text shown on the ShowcaseView. | |
*/ | |
public Builder setContentTitle(CharSequence title) { | |
showcaseView.setContentTitle(title); | |
return this; | |
} | |
/** | |
* Set the descriptive text shown on the ShowcaseView. | |
*/ | |
public Builder setContentText(int resId) { | |
return setContentText(activity.getString(resId)); | |
} | |
/** | |
* Set the descriptive text shown on the ShowcaseView. | |
*/ | |
public Builder setContentText(CharSequence text) { | |
showcaseView.setContentText(text); | |
return this; | |
} | |
/** | |
* Set the target of the showcase. | |
* | |
* @param target a {@link com.github.amlcurran.showcaseview.targets.Target} representing | |
* the item to showcase (e.g., a button, or action item). | |
*/ | |
public Builder setTarget(Target target) { | |
showcaseView.setTarget(target); | |
return this; | |
} | |
/** | |
* Set the style of the ShowcaseView. See the sample app for example styles. | |
*/ | |
public Builder setStyle(int theme) { | |
showcaseView.setStyle(theme); | |
return this; | |
} | |
/** | |
* Set a listener which will override the button clicks. | |
* <p/> | |
* Note that you will have to manually hide the ShowcaseView | |
*/ | |
public Builder setOnClickListener(OnClickListener onClickListener) { | |
showcaseView.overrideButtonClick(onClickListener); | |
return this; | |
} | |
/** | |
* Don't make the ShowcaseView block touches on itself. This doesn't | |
* block touches in the showcased area. | |
* <p/> | |
* By default, the ShowcaseView does block touches | |
*/ | |
public Builder doNotBlockTouches() { | |
showcaseView.setBlocksTouches(false); | |
return this; | |
} | |
/** | |
* Make this ShowcaseView hide when the user touches outside the showcased area. | |
* This enables {@link #doNotBlockTouches()} as well. | |
* <p/> | |
* By default, the ShowcaseView doesn't hide on touch. | |
*/ | |
public Builder hideOnTouchOutside() { | |
showcaseView.setBlocksTouches(true); | |
showcaseView.setHideOnTouchOutside(true); | |
return this; | |
} | |
/** | |
* Set the ShowcaseView to only ever show once. | |
* | |
* @param shotId a unique identifier (<em>across the app</em>) to store | |
* whether this ShowcaseView has been shown. | |
*/ | |
public Builder singleShot(long shotId) { | |
showcaseView.setSingleShot(shotId); | |
return this; | |
} | |
public Builder setShowcaseEventListener(OnShowcaseEventListener showcaseEventListener) { | |
showcaseView.setOnShowcaseEventListener(showcaseEventListener); | |
return this; | |
} | |
} | |
/** | |
* Set whether the text should be centred in the screen, or left-aligned (which is the default). | |
*/ | |
public void setShouldCentreText(boolean shouldCentreText) { | |
this.shouldCentreText = shouldCentreText; | |
hasAlteredText = true; | |
invalidate(); | |
} | |
/** | |
* @see com.github.amlcurran.showcaseview.ShowcaseView.Builder#setSingleShot(long) | |
*/ | |
public void setSingleShot(long shotId) { | |
shotStateStore.setSingleShot(shotId); | |
} | |
/** | |
* Change the position of the ShowcaseView's button from the default bottom-right position. | |
* | |
* @param layoutParams a {@link android.widget.RelativeLayout.LayoutParams} representing | |
* the new position of the button | |
*/ | |
@Override | |
public void setButtonPosition(RelativeLayout.LayoutParams layoutParams) { | |
mEndButton.setLayoutParams(layoutParams); | |
} | |
/** | |
* Set the duration of the fading in and fading out of the ShowcaseView | |
*/ | |
private void setFadeDurations(long fadeInMillis, long fadeOutMillis) { | |
this.fadeInMillis = fadeInMillis; | |
this.fadeOutMillis = fadeOutMillis; | |
} | |
/** | |
* @see com.github.amlcurran.showcaseview.ShowcaseView.Builder#hideOnTouchOutside() | |
*/ | |
@Override | |
public void setHideOnTouchOutside(boolean hideOnTouch) { | |
this.hideOnTouch = hideOnTouch; | |
} | |
/** | |
* @see com.github.amlcurran.showcaseview.ShowcaseView.Builder#doNotBlockTouches() | |
*/ | |
@Override | |
public void setBlocksTouches(boolean blockTouches) { | |
this.blockTouches = blockTouches; | |
} | |
/** | |
* @see com.github.amlcurran.showcaseview.ShowcaseView.Builder#setStyle(int) | |
*/ | |
@Override | |
public void setStyle(int theme) { | |
TypedArray array = getContext().obtainStyledAttributes(theme, R.styleable.ShowcaseView); | |
updateStyle(array, true); | |
} | |
@Override | |
public boolean isShowing() { | |
return isShowing; | |
} | |
private void updateStyle(TypedArray styled, boolean invalidate) { | |
int backgroundColor = styled.getColor(R.styleable.ShowcaseView_sv_backgroundColor, Color.argb(128, 80, 80, 80)); | |
int showcaseColor = styled.getColor(R.styleable.ShowcaseView_sv_showcaseColor, HOLO_BLUE); | |
String buttonText = styled.getString(R.styleable.ShowcaseView_sv_buttonText); | |
if (TextUtils.isEmpty(buttonText)) { | |
buttonText = getResources().getString(android.R.string.ok); | |
} | |
boolean tintButton = styled.getBoolean(R.styleable.ShowcaseView_sv_tintButtonColor, true); | |
int titleTextAppearance = styled.getResourceId(R.styleable.ShowcaseView_sv_titleTextAppearance, | |
R.style.TextAppearance_ShowcaseView_Title); | |
int detailTextAppearance = styled.getResourceId(R.styleable.ShowcaseView_sv_detailTextAppearance, | |
R.style.TextAppearance_ShowcaseView_Detail); | |
styled.recycle(); | |
showcaseDrawer.setShowcaseColour(showcaseColor); | |
showcaseDrawer.setBackgroundColour(backgroundColor); | |
tintButton(showcaseColor, tintButton); | |
mEndButton.setText(buttonText); | |
textDrawer.setTitleStyling(titleTextAppearance); | |
textDrawer.setDetailStyling(detailTextAppearance); | |
hasAlteredText = true; | |
if (invalidate) { | |
invalidate(); | |
} | |
} | |
private void tintButton(int showcaseColor, boolean tintButton) { | |
if (tintButton) { | |
mEndButton.getBackground().setColorFilter(showcaseColor, PorterDuff.Mode.MULTIPLY); | |
} else { | |
mEndButton.getBackground().setColorFilter(HOLO_BLUE, PorterDuff.Mode.MULTIPLY); | |
} | |
} | |
private class UpdateOnGlobalLayout implements ViewTreeObserver.OnGlobalLayoutListener { | |
@Override | |
public void onGlobalLayout() { | |
if (!shotStateStore.hasShot()) { | |
updateBitmap(); | |
} | |
} | |
} | |
private class CalculateTextOnPreDraw implements ViewTreeObserver.OnPreDrawListener { | |
@Override | |
public boolean onPreDraw() { | |
recalculateText(); | |
return true; | |
} | |
} | |
private OnClickListener hideOnClickListener = new OnClickListener() { | |
@Override | |
public void onClick(View v) { | |
hide(); | |
} | |
}; | |
} |